Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 21 additions & 3 deletions cmd/cli/cmd_diff.go
Original file line number Diff line number Diff line change
Expand Up @@ -95,8 +95,12 @@ func execDiff(leftPath, rightPath string, jsonOut, pretty, full bool) (exitCode
// the counts and must be checked explicitly or a /Producer-only or
// encryption-only change would wrongly report "identical" (exit 0).
func diffIsIdentical(s pdfcore.DiffSummary) bool {
// A depth-capped walk (TruncatedSubtrees > 0) left a subtree unexplored, so
// the pair cannot be certified identical even with zero visible deltas
// (Story 14.3 AC2): exit 1 ("not provably identical") is the honest signal.
return s.Added == 0 && s.Removed == 0 && s.Changed == 0 &&
!s.VersionChanged && !s.EncryptionChanged && !s.InfoChanged && !s.XMPChanged
!s.VersionChanged && !s.EncryptionChanged && !s.InfoChanged && !s.XMPChanged &&
s.TruncatedSubtrees == 0
}

// printDiffPlain renders the summary plus a path-indented delta. NON-CONTRACTUAL
Expand All @@ -119,6 +123,11 @@ func printDiffPlain(out io.Writer, res *pdfcore.DiffResult, full bool) error {
if s.XMPChanged {
b.WriteString("XMP metadata changed\n")
}
if s.TruncatedSubtrees > 0 {
// Depth-cap honesty (Story 14.3 AC2): state plainly that the walk was
// bounded so no consumer mistakes it for a complete comparison.
fmt.Fprintf(&b, "%d subtree(s) compared only to the depth cap (truncated); deeper differences cannot be ruled out\n", s.TruncatedSubtrees)
}
b.WriteString("\n")

if diffIsIdentical(s) {
Expand Down Expand Up @@ -158,15 +167,24 @@ func writeDiffLines(b *strings.Builder, n *pdfcore.DiffNode, depth int, full boo
fmt.Fprintf(b, " %s", n.LeftSummary)
}
}
if n.Truncated {
// The depth-cap tag (Story 14.3 AC1): a truncated node carries
// Status "unchanged", so without this it would print without any
// indication its subtree was left unwalked.
b.WriteString(" [truncated: depth cap]")
}
b.WriteString("\n")
for _, c := range n.Children {
writeDiffLines(b, c, depth+1, full)
}
}

// diffNodeHasDelta reports whether n or any descendant is not "unchanged".
// diffNodeHasDelta reports whether n or any descendant is not "unchanged", or
// is depth-cap truncated. A truncated node reports Status "unchanged" but must
// surface in the default (non-full) delta so its [truncated: depth cap] tag is
// visible (Story 14.3 AC1).
func diffNodeHasDelta(n *pdfcore.DiffNode) bool {
if n.Status != "unchanged" {
if n.Status != "unchanged" || n.Truncated {
return true
}
for _, c := range n.Children {
Expand Down
95 changes: 57 additions & 38 deletions cmd/cli/cmd_stream.go
Original file line number Diff line number Diff line change
Expand Up @@ -137,44 +137,41 @@ func execStreamDump(filePath string, flags streamFlags) (exitCode int) {
}
defer func() { _ = inspector.Close("cli") }()

// Resolve the content-stream nodeID and, for --ops, the page number whose
// resources back Do classification (0 = no page-backed Do resolution).
// Resolve the input mode: --ref/--xobject address a single stream; the page
// mode (opsPageNum > 0) resolves to the page's COMPLETE content, and the
// page number also backs Do classification under --ops.
nodeID, opsPageNum, code := resolveStreamNode(inspector, info, flags)
if code != 0 {
return code
}

// No Contents entry on a page: valid PDF, just no stream.
if nodeID == "" {
if flags.ops {
// NDJSON contract: zero lines on stdout, condition on stderr, exit 0.
fmt.Fprintln(os.Stderr, "page has no content stream")
return 0
var result *pdfcore.ContentStreamData
if opsPageNum > 0 {
// Page mode: a page's content is the CONCATENATION of every stream in its
// /Contents array (ISO 32000-1 7.8.2), assembled and tokenized as one
// program - no stream is dropped or presented as if it were the whole.
r, err := inspector.GetPageContentStream("cli", opsPageNum)
if err != nil {
writeJSONError(os.Stderr, err.Error())
return 2
}
result := &pdfcore.ContentStreamData{Raw: "", Error: "page has no content stream"}
if flags.json {
if err := emit(os.Stdout, result, flags.pretty); err != nil {
writeJSONError(os.Stderr, fmt.Sprintf("failed to write output: %v", err))
return 2
}
return 0
if r == nil {
// No /Contents on the page: valid PDF, just no stream.
return writeNoContentStream(flags)
}
// Plain default: surface the no-stream condition as a one-line note (the
// page is valid, just has no /Contents).
if _, err := io.WriteString(os.Stdout, "(page has no content stream)\n"); err != nil {
fmt.Fprintf(os.Stderr, "failed to write output: %v\n", err)
result = r
} else {
// --ref / --xobject: a single addressed stream.
r, err := inspector.GetContentStream("cli", nodeID)
if err != nil {
writeJSONError(os.Stderr, err.Error())
return 2
}
return 0
result = r
}

result, err := inspector.GetContentStream("cli", nodeID)
if err != nil {
writeJSONError(os.Stderr, err.Error())
return 2
}
// GetContentStream returns a non-error ContentStreamData{Error:...} for
// "node is not a stream object" and decode failures. Map that to exit 2.
// GetContentStream/GetPageContentStream return a non-error ContentStreamData{
// Error:...} for "node is not a stream object" and decode failures. exit 2.
if result.Error != "" {
writeJSONError(os.Stderr, result.Error)
return 2
Expand Down Expand Up @@ -207,6 +204,30 @@ func execStreamDump(filePath string, flags streamFlags) (exitCode int) {
return 0
}

// writeNoContentStream renders the "page has no /Contents" condition on the
// selected surface and exits 0 (a valid page, just no stream): zero NDJSON
// lines with the note on stderr for --ops, a JSON object carrying the note for
// --json, and a one-line note on stdout otherwise (plain and --raw).
func writeNoContentStream(flags streamFlags) int {
if flags.ops {
fmt.Fprintln(os.Stderr, "page has no content stream")
return 0
}
if flags.json {
result := &pdfcore.ContentStreamData{Raw: "", Error: "page has no content stream"}
if err := emit(os.Stdout, result, flags.pretty); err != nil {
writeJSONError(os.Stderr, fmt.Sprintf("failed to write output: %v", err))
return 2
}
return 0
}
if _, err := io.WriteString(os.Stdout, "(page has no content stream)\n"); err != nil {
fmt.Fprintf(os.Stderr, "failed to write output: %v\n", err)
return 2
}
return 0
}

// printStreamPlain renders the decoded content stream as a human-readable
// operator listing: one operator per line, operands before the operator in
// PDF content-stream order (let it flow; do not tabulate). NON-CONTRACTUAL;
Expand Down Expand Up @@ -238,10 +259,12 @@ func printStreamPlain(out io.Writer, result *pdfcore.ContentStreamData) error {
return err
}

// resolveStreamNode maps the input flags to a content-stream nodeID. It returns
// the nodeID (or "" when a page has no /Contents), the page number to back Do
// classification under --ops (0 when not a page stream), and a non-zero exit
// code on error (already reported to stderr).
// resolveStreamNode maps the input flags to a content-stream target. For
// --ref/--xobject it returns the addressed stream's nodeID with opsPageNum 0.
// For page mode it returns nodeID "" and opsPageNum = the (validated) page
// number: the caller fetches the page's complete content via
// GetPageContentStream (a single page-dict resolution), and opsPageNum also
// backs Do classification under --ops. code is non-zero on error (reported).
func resolveStreamNode(inspector *pdfcore.Inspector, info *pdfcore.DocumentInfo, flags streamFlags) (nodeID string, opsPageNum int, code int) {
switch {
case flags.xobject != "":
Expand Down Expand Up @@ -272,7 +295,8 @@ func resolveStreamNode(inspector *pdfcore.Inspector, info *pdfcore.DocumentInfo,
return fmt.Sprintf("obj:%d:%d", genNum, objNum), 0, 0

default:
// Page content stream.
// Page mode: validate the range only. GetPageContentStream (caller)
// resolves and concatenates the page's content in a single page-dict pass.
if info.PageCount == 0 {
writeJSONError(os.Stderr, "cannot determine page count for this PDF")
return "", 0, 2
Expand All @@ -281,12 +305,7 @@ func resolveStreamNode(inspector *pdfcore.Inspector, info *pdfcore.DocumentInfo,
writeJSONError(os.Stderr, fmt.Sprintf("page %d out of range: document has %d pages", flags.page, info.PageCount))
return "", 0, 2
}
id, err := inspector.GetPageContentStreamNodeID("cli", flags.page)
if err != nil {
writeJSONError(os.Stderr, err.Error())
return "", 0, 2
}
return id, flags.page, 0
return "", flags.page, 0
}
}

Expand Down
108 changes: 108 additions & 0 deletions frontend/src/components/DiffView.truncation.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
/**
* Story 14.3: DiffView depth-cap truncation display branch (AC5, 14.3-COMP-001).
*
* DiffView's `identical` const (DiffView.tsx) mirrors Go's diffIsIdentical, and
* that includes `summary.truncatedSubtrees === 0`. Given a result whose walk was
* bounded by the depth cap (truncatedSubtrees > 0) but whose visible node counts
* are all zero, the component must NOT compute identical === true and must NOT
* render the "No structural differences" banner without a truncation marker --
* the quiet lie this story closes, mirrored on the GUI surface.
*
* Before the fix these cases were red: `identical` ignored truncatedSubtrees, so
* the banner appeared on a bounded walk. This is the thin display branch of a
* backend-verified field, kept at the component level (NOT E2E).
*
* Naming: 14.3-COMP-001 [P1].
* Run: cd frontend && npx vitest run src/components/DiffView.truncation.test.tsx
*/
import { render, screen, waitFor } from '@testing-library/react';
import { describe, test, expect, vi, beforeEach } from 'vitest';
import { DiffView } from './DiffView';

const mockDiffDocuments = vi.fn();
vi.mock(
'../../bindings/unidoc-pdf-debugger/internal/pdfservice/pdfservice.js',
() => ({
DiffDocuments: (...a: unknown[]) => mockDiffDocuments(...a),
})
);

/**
* A diff whose visible node counts are all zero but whose walk was bounded by
* the depth cap (truncatedSubtrees > 0). Under the bug this reports identical;
* post-fix it must NOT. The single cut node carries `truncated: true`.
*/
const depthCappedResult = {
summary: {
added: 0,
removed: 0,
changed: 0,
pageCountLeft: 1,
pageCountRight: 1,
versionChanged: false,
encryptionChanged: false,
infoChanged: false,
xmpChanged: false,
// Additive field surfaced by the Go DiffSummary (AC2); declared on
// DiffSummaryData in DiffView.tsx.
truncatedSubtrees: 1,
},
root: {
path: '/Root',
status: 'unchanged',
kind: 'dict',
changedKeys: [] as string[],
leftSummary: '',
rightSummary: '',
children: [
{
path: '/Root/Deep',
status: 'unchanged',
kind: 'ref',
changedKeys: [] as string[],
leftSummary: '<< /L <ref> >>',
rightSummary: '<< /L <ref> >>',
truncated: true,
children: [],
},
],
},
};

beforeEach(() => {
vi.clearAllMocks();
mockDiffDocuments.mockResolvedValue(depthCappedResult);
});

describe('DiffView depth-cap truncation (Story 14.3)', () => {
// 14.3-COMP-001 [P1] AC5: a result with truncatedSubtrees > 0 must NOT render
// the "No structural differences / identical" banner -- the walk was bounded,
// so identity cannot be claimed.
test('14.3-COMP-001 suppresses the identical banner when a subtree was depth-capped', async () => {
render(<DiffView leftTabId="left" rightTabId="right" active />);

const summary = await screen.findByTestId('diff-summary');
const text = (summary.textContent ?? '').toLowerCase();
expect(text).not.toMatch(/no structural differences|no differ|identical/);
});

// 14.3-COMP-001 [P1] AC5: the per-node [truncated: depth cap] ROW renders, not
// just the summary note. The depth-capped node reports status "unchanged", so
// hasDelta must treat `truncated` as a delta for its ancestors to auto-expand;
// otherwise the marker sits under an unexpanded ancestor and is unreachable.
// Asserts the bracketed row text (distinct from the summary note's "truncated
// at the depth cap") AND the cut node's path, so it genuinely covers the
// DiffView.tsx per-node marker branch rather than passing on the summary note.
test('14.3-COMP-001 auto-expands to the depth-cap node and renders its row marker', async () => {
render(<DiffView leftTabId="left" rightTabId="right" active />);

await waitFor(() => expect(mockDiffDocuments).toHaveBeenCalled());
await screen.findByTestId('diff-summary');

// The cut node itself is rendered (its ancestors auto-expanded to reach it).
expect(screen.getAllByText('/Root/Deep').length).toBeGreaterThan(0);
// ...carrying the per-node marker: bracketed row text, which the summary
// note ("... truncated at the depth cap ...") does not contain.
expect(screen.getAllByText(/\[truncated: depth cap\]/).length).toBeGreaterThan(0);
});
});
25 changes: 22 additions & 3 deletions frontend/src/components/DiffView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ export interface DiffNodeData {
leftSummary: string;
rightSummary: string;
children?: DiffNodeData[];
/** True when this ref was left unwalked at the depth cap (Story 14.3 AC1). */
truncated?: boolean;
}

/** The document-level tally, mirroring `pdfcore.DiffSummary`. */
Expand All @@ -33,6 +35,9 @@ export interface DiffSummaryData {
encryptionChanged: boolean;
infoChanged: boolean;
xmpChanged: boolean;
/** Count of subtrees compared only to the depth cap (Story 14.3 AC2); when
* > 0 the walk was bounded and identity cannot be claimed. */
truncatedSubtrees: number;
}

/** The diff outcome, mirroring `pdfcore.DiffResult`. */
Expand All @@ -51,9 +56,12 @@ export interface DiffViewProps {
active: boolean;
}

/** Reports whether a node or any descendant is not "unchanged". */
/** Reports whether a node or any descendant is not "unchanged", or is a
* depth-cap truncated ref. A truncated node reports status "unchanged" but must
* still route auto-expansion so its [truncated: depth cap] marker is reachable
* without hand-expanding every level (mirrors Go's diffNodeHasDelta). */
function hasDelta(node: DiffNodeData): boolean {
if (node.status !== 'unchanged') return true;
if (node.status !== 'unchanged' || node.truncated) return true;
return (node.children ?? []).some(hasDelta);
}

Expand Down Expand Up @@ -238,14 +246,18 @@ export function DiffView({ leftTabId, rightTabId, active }: DiffViewProps) {
// Mirror Go's diffIsIdentical: node counts alone miss document-level deltas
// (encryption, /Version, /Info, XMP) that live off the catalog walk, so a
// flags-only change must not report "No structural differences".
// A depth-capped walk (truncatedSubtrees > 0) left a subtree unexplored, so
// identity cannot be claimed even with zero visible deltas (Story 14.3 AC2/
// 14.3-COMP-001), mirroring Go's diffIsIdentical.
const identical =
s.added === 0 &&
s.removed === 0 &&
s.changed === 0 &&
!s.versionChanged &&
!s.encryptionChanged &&
!s.infoChanged &&
!s.xmpChanged;
!s.xmpChanged &&
s.truncatedSubtrees === 0;

return (
<div className="h-full flex flex-col" data-testid="diff-view">
Expand All @@ -264,6 +276,12 @@ export function DiffView({ leftTabId, rightTabId, active }: DiffViewProps) {
{s.infoChanged ? ' | /Info changed' : ''}
{s.xmpChanged ? ' | XMP changed' : ''}
</div>
{s.truncatedSubtrees > 0 && (
<div className="text-warning mt-0.5" data-testid="diff-truncation-note">
{s.truncatedSubtrees} subtree{s.truncatedSubtrees === 1 ? '' : 's'} truncated at the depth
cap; deeper differences cannot be ruled out.
</div>
)}
</div>

<div className="flex items-center gap-2 px-3 py-1.5 border-b border-border bg-surface flex-shrink-0">
Expand Down Expand Up @@ -329,6 +347,7 @@ export function DiffView({ leftTabId, rightTabId, active }: DiffViewProps) {
<span>{diffMarker(node.status)} </span>
<span>{node.path}</span>
{node.leftSummary ? <span className="text-text-muted"> {node.leftSummary}</span> : null}
{node.truncated ? <span className="text-warning"> [truncated: depth cap]</span> : null}
Comment thread
unidoc-alip marked this conversation as resolved.
</div>
);
})}
Expand Down
Loading