19 commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
| a82c8f7ff7 |
feat(pdf): Unicode-aware search and layout-aware reading order
Some checks failed
nigig-build (CAD) / supply-chain (push) Has been cancelled
nigig-build (CAD) / cad-module (push) Has been cancelled
nigig-build (CAD) / full-crate-check (push) Has been cancelled
nigig-build (CAD) / cad-engine-coverage (push) Has been cancelled
nigig-build (CAD) / doc-workspace-coverage (push) Has been cancelled
nigig-build (CAD) / cad-widget-coverage (push) Has been cancelled
repo hygiene / hygiene (push) Has been cancelled
spreadsheet / engine-coverage (push) Has been cancelled
spreadsheet / ui-controller-coverage (push) Has been cancelled
doc-engine / engine (push) Has been cancelled
doc-engine / coverage (push) Has been cancelled
sms / robius-sms (push) Has been cancelled
sms / android (push) Has been cancelled
sms / nigig-sms (push) Has been cancelled
sms / supply-chain (push) Has been cancelled
doc-engine / consumer (push) Has been cancelled
email / gates (push) Has been cancelled
email / email-domain (push) Has been cancelled
email / nigig-email (push) Has been cancelled
email / supply-chain (push) Has been cancelled
nigig-map / test (push) Has been cancelled
PDF engine / engine (push) Has been cancelled
PDF engine / makepad-integration (push) Has been cancelled
PDF engine / fuzz (push) Has been cancelled
sms / gates (push) Has been cancelled
Phase 8 bullets one and two. Probing the existing code first, as the
workflow requires, found five defects rather than the one the plan names:
SPLIT MATCH 'Hello': 0 hits
plain_text: "Hello"
PRECOMPOSED 'café': 0 hits
COLUMNS plain_text: "LeftTopRightTop\nLeftBotRightBot"
OUT OF ORDER plain_text: "second\nfirst"
`PageText::find` searched one run at a time and documented that as a known
limitation. It is a limitation from inside the code and a broken feature
from outside it: a writer starts a new run wherever it adjusts kerning, so
an ordinary word arrives as two runs, and the find bar says a word plainly
visible on the page is not there.
`search.rs` indexes the page as one flattened string with a map back to
(run, character), so a cross-run match is found and highlighted with one
rectangle per run — never a merged box, which across a line break covers
half the paragraph.
The separator between two runs is a geometric question with three answers:
abutting runs join with nothing (one word, split by kerning), separated
runs with a space, and a different line or column with a newline. The
newline matters as much as the empty join: joining lines with a space lets
"one Right" match across a column gutter, text that appears nowhere.
Whether two runs share a column is *asked* of the layout analysis rather
than re-derived, or the extracted text and the searched text disagree about
where a column ends — the original defect wearing a different hat.
NFD, never NFC: composition needs the next character, so an NFC fold
applied per character composes nothing and the two spellings of an accent
stay different. That was a real bug in the first draft. And case *folding*,
not lowercasing — Rust lowercases ß to ß, so "Strasse" never found
"Straße".
Columns are detected before lines, because two columns share their
baselines; that is what makes them columns. Bands are separated by a gutter
rather than by bare non-overlap, since two abutting runs on a line do not
overlap either.
Also fixed, found by running the gates rather than by looking: a stream
reader trimmed a trailing CR before `endstream` as if it were the writer's
separator. Binary data ends in CR about one time in 256, and when it did
the reader returned a stream one byte short — no longer AES-block-aligned,
so decryption produced garbage and Flate failed. Roughly one encrypted
document in 250 was silently corrupt on read. The test failed once under
coverage, passed five times in isolation, and failed 2 in 40 when actually
counted. A /Length consistent with the file is now the authority; both
stream readers are fixed and a test reads one file through each.
1477 tests pass (was 1426), coverage 88.37%, all floors met, external
readers pass. 10 mutations across the two modules, all killed.
ADR 0034.
|
|||
| 1740da3f34 |
feat(pdf): render a form XObject to pixels — the golden caught what the
assertions missed `Rasteriser::register_xobject` takes a form's recorded commands from a caller that can resolve the page dictionary, so Phase 7's last golden-corpus criterion is met with pixels instead of with a request recorded by name. The first golden of that page showed the form drawn at the **page origin**, ignoring the `1 0 0 1 20 20 cm` that placed it. The recorded commands' `SetTransform`s are absolute in form space, and replaying them overwrote the page's CTM rather than composing with it. Nested lists now compose against the CTM in force at the `Do`. The colour assertions written next to that golden all passed while the bug was live — a red square two pixels from where it belongs is still a red square somewhere. That is the argument for pixel goldens in one sentence, and it is why the golden is compared after the assertions and not instead of them. The offset now has its own assertion too. The new fixture's form deliberately overflows its own /BBox, so the clip is visible in the golden as an absence rather than being taken on trust. Phase 7's golden-corpus exit criterion is now met in full. The `ui.rs` smoke tests remain blocked on the Makepad headless backend, as they have been since Phase 1, and are still not claimed as done. 1426 tests pass. |
|||
| d3089bc62a |
feat(pdf): nested content, the wire codec and tiled rendering — Phase 7 closed
Three bullets, and the Phase 7 status table rewritten row by row. **Nested content (ADR 0032).** A form XObject and a Type 3 glyph are the same problem: a content stream inside a content stream. Both were parsed completely and then not run. `paint_x_object` reported the name for "the host" to resolve and no host existed, so `Do` painted nothing. Type 3 was worse because it looked more correct — `d0`/`d1` reached the device, so the pen advanced by the declared width and the page rendered an invisible line of text with correct spacing after it. `nested.rs` runs both, in pdf-graphics because the dependency runs graphics → document and this is the only crate that can see the interpreter and the object model at once. Forms get their `/Matrix`, their `/BBox` clip and a save/restore wrapper, because without the wrapper a form's colour leaks into every object after it and looks like a bug in the document. Type 3 composes translate-then-matrix; the other order scales the translation and puts the glyph at (1.7, 16.8) instead of (72, 700). Recursion is bounded in both: unbounded, a self-referencing form is a stack overflow reachable from an untrusted document, which is a denial of service and not a rendering bug. **Wire codec and tiling (ADR 0033).** `worker.rs` moved interpretation off the UI thread only because both ends shared a Vec. Tags are explicit numbers, never declaration order, so reordering the enum cannot silently make old recordings decode as different commands. Truncation is an error rather than a short list — a decoder that stopped early would render a page missing its last few operations, plausible and wrong. The obvious truncation test failed, correctly: `Save` is one byte, so a cut on a command boundary really is a complete list. It now tries every cut position and requires each to be a named error or a genuine prefix. Tile skipping is conservative. A command whose geometry is unknown is kept, because dropping a state change corrupts everything after it in that tile, silently. Only untransformed geometry that provably falls outside is dropped. Every tile is asserted pixel-identical to that region of the whole-page render: tiling that is fast and different is not an optimisation. Eight mutations across the two modules, all killed. Phase 7 status is now two tables — the eight spec bullets and the exit criteria — with what is missing named in the row rather than rounded up. Three rows are not green: Makepad blend compositing needs render-to-texture, the image-XObject pixel golden asserts the request rather than pixels, and the `ui.rs` smoke tests remain blocked on the headless backend they have been blocked on since Phase 1. 1425 tests pass, coverage 88.10% (was 87.60%), all floors met, external readers pass. ADRs 0032 and 0033. |
|||
| c1d1e67f3a |
feat(pdf): shadings — the sh operator was parsed and thrown away
ADR 0028, the first of Phase 7's eight bullets.
content.rs contained `PdfOp::Shading(_name) => {}`. The operator was lexed,
given its own variant, matched during interpretation, and discarded. A page
whose background is a gradient rendered as nothing.
Nothing caught it for the usual reason: a blank region is a legal thing for
a page to contain, so "drew nothing" and "drew what was asked" are
indistinguishable without an assertion naming the expected colour. The
golden corpus had no shading page, so there was nothing to be wrong.
Two of the three pieces already existed — function.rs evaluates the colour
function and colorspace.rs converts it to RGB. What was missing was the
geometry between them.
Sampling rather than a gradient primitive: a PDF shading is defined by an
arbitrary function, possibly a sampled table or a PostScript program, and
neither reduces to a stop list without loss. A device with a native
gradient can still recognise the two-stop case from the samples.
"No colour here" is None, not black. Black is a colour a shading can
legitimately produce, so returning it for "outside an unextended shading"
would paint a rectangle the author never asked for and the caller could not
tell the two apart.
Types 1-5 exact. Coons and tensor patches are flattened to their corners,
which loses the curvature, and is_approximate says so rather than leaving a
caller to assume fidelity. An unknown type is refused by number: a mesh
drawn as a flat fill is a plausible-looking wrong answer.
paint_shading is a new trait method, so the compiler found every
implementor. The Makepad renderer records the request in pending_shadings,
mirroring pending_xobjects — it cannot resolve a /Shading resource because
it does not own the page dictionary, and recording the request is what
stops the operator vanishing a second time. That holds even for types we
refuse, so a host can warn the user.
Four mutations, all killed. The first — discarding sh again — fails three
tests.
Stated plainly and left unticked: the mesh path is written but NOT
exercised by any real stream. shading.rs is at 68% and the uncovered part
is exactly parse_mesh and triangulate. Mesh support should be treated as
unproven, not working: the code runs and produces triangles, and nothing
yet demonstrates they are the right triangles. That is the position
image.rs was in before ADR 0016 found the JPEG decoder was a stub.
The Phase 7 status line is a table from the start this time — one row per
spec bullet, seven of them saying "not started". Per ADR 0021, written
before the work rather than after it.
pdf: 1321 passed (was 1291). pdf-ui: 1366. Coverage 87.60%, floors met.
|
|||
| 374af5ccad |
feat(pdf): the five Phase 6 bullets the status line omitted
ADR 0027. Asked whether Phase 6 was 100% complete, I checked the plan's bullets against the code instead of answering from the status line. Five were not implemented and the status line named none of them: detached/ATTACHED signatures /SubFilter hardcoded to adbe.pkcs7.detached PAdES basics ETSI.CAdES.detached was a string in a match external_signing_test.dart absent; SigningIdentity needs an in-memory key OCSP/CRL lookup CRL only; OCSP counted, never parsed Fulcio identity absent (optional in the plan) This is the second time. ADR 0021 recorded the same failure in Phase 4 and wrote the rule meant to prevent it — enumerate criteria from the plan text first, then mark each done or explicitly deferred. I wrote that rule and then produced another prose summary of what I had built. A summary written from the work cannot show what the work omitted. PAdES is a real profile, not a label. CAdES signs a set of signed attributes, one carrying the document digest, and the signature is over those attributes re-tagged as a SET (RFC 5652 5.4) rather than over the [0] IMPLICIT SEQUENCE they are carried in. Verification checks the messageDigest attribute against the document as well as verifying the attribute signature; without that, a signature over somebody else's digest would be accepted. /SubFilter now comes from the profile, so a document cannot claim CAdES while carrying plain PKCS#7. ExternalSigner is a trait: bytes in, signature out. A smartcard or KMS never hands out its key, so SigningIdentity could not represent one. SigningIdentity implements the trait rather than sitting beside it, so there is one signing path — a second path for hardware keys would be a second place the byte range could be computed differently. OCSP is decoded with the der crate already present rather than adding the ocsp crate for two fields. Revoked from any response beats Good from any other. Attached signatures are REFUSED, not deferred. Both attached profiles (adbe.pkcs7.sha1, adbe.x509.rsa_sha1) are SHA-1 based, and SHA-1 is broken for signatures. They are parsed so such documents can be read; they cannot be written, enforced by the absence of a SignatureProfile variant. Same decision as RC4 in ADR 0024. Recorded as refused rather than not-done, because "not done" invites someone to finish it. Four mutations, all killed first attempt: messageDigest not compared, CAdES verified against the wrong bytes, /SubFilter hardcoded again, OCSP revoked read as good. The status line is now the plan's own bullets in a table, one row per spec item, not prose. Two wrong status lines in the same direction is a pattern, and the fix is structural: a missing row is visible, a missing sentence is not. Four rows are left unticked — Fulcio, independent review, Acrobat interoperability, and signing a document that already has an AcroForm. qpdf accepts documents under both profiles. pdf: 1289 passed (was 1276). Coverage 87.96%. |
|||
| 99aebc202a |
fix(pdf): security review of the signing code — a forgery verified as valid
ADR 0026. Both ADR 0024 and ADR 0025 said this code needed a security review before shipping. This is that review, done adversarially: for each way a signature could be defeated, a test that attempts it. It found a critical vulnerability in the code as shipped last turn. FINDING 1, critical, exploitable with no special access. Verification recovered the certificate and the signature by *scanning* the blob for DER-shaped bytes rather than decoding it. The signature was checked against certificates[0]; trust was checked against ANY certificate present. Two questions, two different certificates. So: the attacker signs a forgery with their own key the attacker appends the victim's trusted certificate to the blob signature_valid = true (their signature over their own content is real) chain_trusted = true (the victim's certificate is present) is_valid() = true Demonstrated before the fix, with the message "I hereby transfer everything to the attacker" verifying as valid. Fixed by decoding the ContentInfo/SignedData structure and finding the certificate the SignerInfo actually names, by issuer AND serial, then evaluating both the signature and the trust path against that one certificate. Trailing data now fails the decode instead of being ignored. The scanning functions are deleted, not left unused: dead code that once returned the wrong answer is an invitation to call it again. FINDING 2, moderate. signer_certificate() returned chain[0] unconditionally, so a chain whose first entry was not the signing key's certificate made the SignerInfo name the wrong one. Not a forgery route — the signature fails — but a UI showing "signed by <somebody trustworthy>" beside a failed check is its own kind of dangerous. Now it finds the entry whose public key matches the key doing the signing. FINDING 3, informational. digest_matches was hardcoded true under a comment claiming it was computed. Not exploitable, because is_valid() also requires signature_valid and the signature covers the bytes — but a field asserting an unperformed check is ADR 0017's pattern exactly. The four items ADR 0025 left unticked are closed: PKIX chain building, with each link's issuer signature verified. A name match alone is not a chain; anyone can put any name in a certificate. Pinning still short-circuits first. Stapled revocation from /DSS, offline only. Unknown is the default and a first-class answer: treating "no information" as "not revoked" is a claim a verifier cannot support. Signature appearances, with the claimed time labelled "Time claimed" because a self-declared /M carries no authority. One-call sign_document. Three things were wrong first: the /ByteRange placeholder was too narrow for real offsets so patching them moved every later byte; /Contents must be a hex string because a literal full of NULs needs escaping and changes length; and a signature dictionary nothing points at is invisible — the first version wrote one and the reader reported zero signatures over a correctly signed document. Four mutations, all killed — two only after strengthening the tests. My first smuggling test put the attacker's certificate first, where certificates[0] finds it anyway, so it passed with or without the issuer/serial match. Putting the TRUSTED certificate first is what distinguishes them, and writing that test is what exposed Finding 2. qpdf --check accepts the signed documents. pdf: 1276 passed. Coverage 87.98%. Left unticked, deliberately: an independent review by someone who did not write the code. This is a self-review; it found two real vulnerabilities, which is evidence the method works and not evidence that nothing remains. Also untested against Acrobat, which is stricter than the spec, and sign_document replaces rather than merges an existing AcroForm. |
|||
| 9a5ce9c0e6 |
feat(pdf): signing and verification — Valid becomes reachable, with a policy
Some checks failed
nigig-build (CAD) / supply-chain (push) Has been cancelled
nigig-build (CAD) / cad-module (push) Has been cancelled
nigig-build (CAD) / full-crate-check (push) Has been cancelled
nigig-build (CAD) / cad-engine-coverage (push) Has been cancelled
nigig-build (CAD) / doc-workspace-coverage (push) Has been cancelled
repo hygiene / hygiene (push) Has been cancelled
email / gates (push) Has been cancelled
email / email-domain (push) Has been cancelled
email / nigig-email (push) Has been cancelled
email / supply-chain (push) Has been cancelled
PDF engine / engine (push) Has been cancelled
PDF engine / makepad-integration (push) Has been cancelled
PDF engine / fuzz (push) Has been cancelled
doc-engine / engine (push) Has been cancelled
doc-engine / coverage (push) Has been cancelled
doc-engine / consumer (push) Has been cancelled
nigig-map / test (push) Has been cancelled
sms / gates (push) Has been cancelled
sms / robius-sms (push) Has been cancelled
sms / android (push) Has been cancelled
sms / nigig-sms (push) Has been cancelled
sms / supply-chain (push) Has been cancelled
ADR 0025, completing Phase 6's functional core. This partially reverses ADR 0010, which refused signing and cryptographic verification outright, and it reverses only the half whose justification expired. ADR 0010 gave two reasons. The first — a parsing library has no business signing — stopped being true when Phase 4 began creating documents and Phase 5 editing them. The second is still true and is preserved intact: deciding which certificate authorities to trust is a policy decision that belongs to the host, not to a parsing library So VerificationStatus::Valid is still not reachable by default. Verification returns three independent booleans and is_valid() needs all three; the third, chain_trusted, can only become true through a caller-supplied TrustAnchors. There is no TrustAnchors::system(), no bundled root store, no Default that trusts anything. A caller with no policy is told "cryptographically intact, signed by somebody you have not said you trust" — a different fact from "forged", and a host that cannot tell them apart shows the wrong thing to a user. RSA PKCS#1 v1.5, ECDSA P-256 and Ed25519, all with SHA-256. PSS is stronger and not universally accepted by PDF verifiers, so v1.5 is what is written. Ed25519 carries an interoperability caveat in the doc comment on the variant itself, because that is where someone choosing it will read it: ISO 32000-2 does not list it and most desktop viewers will reject it. No network. Revocation is not implemented rather than smuggled in: the engine crates are CI-gated against reaching outward, and that gate is a rule about layering, not an obstacle to work around. Every test generates a real key and a real certificate at run time. Nothing asserts against a checked-in blob — a fixed expectation only proves the code still does what it did, which is the wrong question for a signature. The tampering tests assert the signature verifies FIRST, then flip a bit; without that half they could pass by never verifying anything. Four mutations, all killed. The one that matters is the first: making an empty anchor set confer trust is exactly the regression that would turn this back into the thing ADR 0010 refused, and it fails immediately. Two bugs the tests found: UTCTime cannot encode a year past 2049 (RFC 5280 4.1.2.5.1). The first fixture used a 2096 expiry and every certificate failed to encode. The certificate scanner assumed a two-byte DER length. RSA certificates are large enough to use that form, so RSA and P-256 passed while Ed25519 found no certificate at all — its certificate is small enough for the short form. A scanner tested only against the largest input fails silently on the smallest. 72 dependency packages pulled in, zero non-compliant licences, no C. Stated plainly and left unticked in the ADR: chain_trusted is anchor identity matching, not PKIX path building. Correct for certificate pinning, a false negative for a real CA hierarchy. Also outstanding: revocation, signature appearance generation, and one-call incremental signing. pdf: 1247 passed. Coverage 88.08%, floors met. |
|||
| d4e3e9a443 |
feat(pdf): encryption on save — AES-128 and AES-256 (Phase 6, part one)
Some checks failed
nigig-build (CAD) / supply-chain (push) Has been cancelled
nigig-build (CAD) / cad-module (push) Has been cancelled
nigig-build (CAD) / full-crate-check (push) Has been cancelled
nigig-build (CAD) / cad-engine-coverage (push) Has been cancelled
nigig-build (CAD) / doc-workspace-coverage (push) Has been cancelled
repo hygiene / hygiene (push) Has been cancelled
doc-engine / engine (push) Successful in 21s
doc-engine / coverage (push) Successful in 31s
doc-engine / consumer (push) Failing after 16m57s
email / gates (push) Has been cancelled
email / email-domain (push) Has been cancelled
email / nigig-email (push) Has been cancelled
email / supply-chain (push) Has been cancelled
nigig-map / test (push) Has been cancelled
PDF engine / engine (push) Has been cancelled
PDF engine / makepad-integration (push) Has been cancelled
PDF engine / fuzz (push) Has been cancelled
sms / gates (push) Has been cancelled
sms / robius-sms (push) Has been cancelled
sms / android (push) Has been cancelled
sms / nigig-sms (push) Has been cancelled
sms / supply-chain (push) Has been cancelled
ADR 0024. This reverses ADR 0005's "never write encryption", and the reason it is safe to reverse is that the facts changed underneath it. A crate that only reads cannot produce weak ciphertext, so refusing to write any was free. Now that Phase 4 creates documents and Phase 5 edits them, the refusal does something worse than protect nobody: open a password-protected file, change one annotation, save, and the output is plaintext. No error, no warning — the protection is silently dropped. That is this project's recurring failure mode in the one place where the consequence is a breach. The principle survives in a narrower form: no hand-rolled crypto, and no weak cipher offered as an option. RC4 stays readable because files use it and is not writable — EncryptionAlgorithm has no RC4 variant, so the refusal is a type, not a runtime check someone can route around. The encryptor is the literal inverse of the decryptor and imports its primitives rather than restating them; two implementations of one algorithm drift, and here they drift towards "decrypts to garbage". Every unit test round-trips through the existing Decryptor. Encryption sits at one choke point: PdfWriter holds the Encryptor and write_object_at encrypts everything passing through. Not per call site — there are twenty-two of those in PdfDocBuilder, and one stream written in the clear inside an encrypted document is not a partial failure, it is a leak that no reader will report because the file is otherwise valid. The /Encrypt dictionary is the single deliberate exemption: it holds the salts a reader needs before it has a key, so encrypting it bricks the file. Verified against implementations we share no code with, now gated in CI: ok qpdf opens it with the password ok it really is AES-256 ok the wrong password is refused ok poppler decrypts the content ok no plaintext in the encrypted file Four mutations, all killed — two only after the tests were strengthened, and both misses are the interesting part: A fixed IV survived two_saves_of_one_document_are_not_byte_identical, because the AES-256 file key is fresh per save and that alone makes the output differ. The property actually needed is narrower: one encryptor, identical plaintext, different bytes. In CBC a repeated IV under one key leaks that two plaintexts are equal. A wrong /Length survived because our own reader recovers by scanning for endstream — a robustness fix from ADR 0023. An independent reader that trusts /Length reads a truncated stream and decrypts garbage. A lenient reader hides a broken writer, which is why the external gate exists. The /Length test itself had a bug first: it searched a from_utf8_lossy view and reported a stream declaring 80 bytes holding 156. Ciphertext is not UTF-8; the replacement characters shifted every offset. Unencrypted output stays byte-reproducible; encrypted output cannot be, and a test asserts that loss rather than leaving it implicit. pdf: 1220 passed (was 1187). pdf-ui: green. Coverage 88.21%, encrypt_write.rs at 96.5%. Signing is NOT started. It needs the trust-anchor decision ADR 0010 deferred: VerificationStatus::Valid is unreachable by construction, and making sign -> verify pass is a policy change, not an implementation detail. The plan's Phase 6 status now says so. |
|||
| ad3fe19b90 |
docs(pdf): the four missing ADRs — codecs, Phase 4 completion, editing, redaction
Some checks failed
repo hygiene / hygiene (push) Has been cancelled
Ten PDF feature commits landed without an ADR, covering three whole phases.
Every feature gets one; these are the four that were owed. Written against
the code as it stands and re-verified by running it, not transcribed from
the commit messages.
0020 CCITT, JBIG2 and JPEG 2000 — the codecs ADR 0015 refused by name
0021 Phase 4 completion — stamping, reconciliation, CFF, cmap, and the
audit that corrected a false "complete" in ADR 0019
0022 Editing — content_edit, page_ops, flatten, catalog_edit
0023 Redaction and compaction, and the three reader defects they found
Verified rather than assumed, on the tree at
|
|||
| b870dc4c69 |
feat(pdf): outline, page label and struct-tree editing — Phase 5 complete
Some checks failed
email.yml / feat(pdf): outline, page label and struct-tree editing — Phase 5 complete (push) Failing after 0s
repo hygiene / hygiene (push) Has been cancelled
PDF engine / engine (push) Has been cancelled
PDF engine / makepad-integration (push) Has been cancelled
PDF engine / fuzz (push) Has been cancelled
The last functional item. `catalog.rs` read all three; nothing could write them into an existing document. `PdfDocBuilder` can emit an outline when *creating* a file, but a document already on disk could not have its bookmarks changed. An outline is a doubly-linked tree — `/First`, `/Last`, `/Next`, `/Prev`, `/Parent` and a signed `/Count` — and every pointer has to agree. A viewer walking `/Next` and one walking `/First`..`/Last` must see the same list, or bookmarks vanish in one reader and not another with no error anywhere. Object numbers are reserved before any dictionary is built, because each item names its parent, its siblings and its children. `/Count` is signed and that matters: positive means open and counts *visible* descendants, negative means closed. A closed child contributes itself but hides its own children. Writing the total unconditionally makes every node render expanded. Page labels are a number tree, so the keys are sorted before writing and two rules starting on the same page are refused — that page's label would be undefined, and picking one arbitrarily is worse than saying so. Struct-tree editing is deliberately **removal only**. Editing the tree in place means rewriting `/K` arrays whose entries are marked-content ids inside page content streams; the tree and the content must stay in step, and changing one without the other produces a document whose accessibility information describes content that is no longer there. Removal is honest — the document stops claiming to be tagged — and `/MarkInfo` goes with it, because `/Marked true` with no tree tells a screen reader there is structure to find. Verified by mutation, seven defects, all caught: /Prev never written 1 fail /Next never written 5 fail /Count always positive 1 fail page validation skipped 2 fail /MarkInfo left behind 1 fail children not linked via /First 2 fail label rules not sorted 1 fail The last one needed a new test. Our reader walks `/Nums` linearly, so it tolerates any order and the round-trip passed unsorted — but a conforming reader binary-searches it and would label pages arbitrarily. Only reading the raw array catches that, which is the same lesson as the stale `/Count` in the page-ops tranche: our parser's tolerance hides defects that harm other readers. Engine suite 1158 -> 1187. External readers still pass. **Phase 5 is complete** but for the `ui.rs` interaction tests, blocked on the same missing Makepad headless backend as Phase 4's. The plan records the item-by-item status and, separately, the six defects the round-trip tests found in code that already existed — an unordered dictionary writer that made every generated PDF differ run to run, a short /Length that silently truncated streams, two readers disagreeing by a byte, a nested paren that truncated a string and desynchronised the stream, undecoded # escapes in names, and unknown operators being dropped outright. |
|||
| 1220f89fc6 |
feat(pdf): close the last three Phase 4 items — reconciliation, CFF, cmap
Some checks failed
email.yml / feat(pdf): close the last three Phase 4 items — reconciliation, CFF, cmap (push) Failing after 0s
repo hygiene / hygiene (push) Has been cancelled
PDF engine / engine (push) Has been cancelled
PDF engine / makepad-integration (push) Has been cancelled
PDF engine / fuzz (push) Has been cancelled
The three items the previous commit's audit found unimplemented while the status line said "complete". All three are done and externally verified. **1. Field-value reconciliation** (`reconcile.rs`). A field carries its value in /V and its rendered look in /AP, and nothing in the format keeps them in step. Files arrive with them disagreeing all the time: a producer writes /V and leaves appearances to the viewer, or something edits /V without touching /AP. Until now this crate simply believed /V and regenerated appearances only for fields it had itself edited — right for a field we changed, wrong for a field that arrived inconsistent. The module deliberately does **not** pick a winner. PDF 32000-1 §12.7.3.3 settles exactly one case — /NeedAppearances true means /V is authoritative — and is silent on the other, where a conforming viewer renders /AP and never looks at /V. So it classifies the disagreement and resolves it against a caller-declared `Intent`, because the right answer genuinely differs: a viewer must show /AP to match other viewers, an extractor must read /V, an editor must regenerate so the saved file agrees with itself. Silently choosing one would be ADR 0017's failure in a new place — every answer plausible, none checkable, the caller unaware a decision was made for it. Two cases are not judgement calls and are handled outright. A missing or dangling appearance renders *blank*, and blank is never what the producer meant, so even Display regenerates. An unselected radio member showing /Off while the group's /V names another member is correct, not a conflict — reporting it would flag every well-built radio group there is. **2. Type1/CFF embedding** (`embed_opentype_whole`). The spec says "if feasible". Subsetting CFF is not — it means rebuilding the CFF INDEX, charset and charstrings, a second font format inside the first — and `subset_truetype` rightly keeps refusing it by name. Embedding the program *whole* is feasible, and that is what this does: /FontFile3 with /Subtype /OpenType under a CIDFontType0 descendant, per Table 126. Each of those keys matters and none is guessable from the others. A CFF program in /FontFile2, or under a CIDFontType2 descendant, still produces a file qpdf accepts and a font that loads as the wrong type or not at all. /CIDToGIDMap is omitted because it is defined for CIDFontType2 only. The trade is made visible rather than buried: `EmbeddedFont::is_subsetted` is false here, so a caller with a size budget — or a licence that forbids shipping a whole face — can refuse instead of discovering it from the output size. **3. `repair-cmap`** (`glyph_index`). A symbol font declares no Unicode subtable: it maps glyphs into the private-use area at 0xF000 + the low byte under platform 3, encoding 0. Asking it for 'A' found nothing and the character silently vanished from the output — the font "missing" a glyph it plainly has. Now the (3,0) subtable is kept as a fallback and retried at 0xF000 + low byte, after the proper lookup fails so a font with both subtables is still read through the Unicode one. Format 0 is read too; omitting it left legacy and symbol fonts mapping nothing while appearing to have a usable cmap. The repair must not manufacture glyphs, which is its own test: a character the font genuinely lacks still returns None, because turning a missing character into a wrong one is worse. **Fixtures.** No CFF or symbol font ships on the CI image, and neither can be tested honestly against a hand-built stub — the point is that the bytes are a font program a third-party reader accepts. Both are generated from DejaVu by checked-in fontTools scripts: `cff_sample.otf` (1.6 KB, real OTTO/CFF outlines) and `symbol_sample.ttf` (664 B, a single (3,0) subtable so the repair path is the only route to its glyphs). Both generators pin `head.created`/`head.modified` to zero. fontTools stamps the current time, so the output differed on every run and CI's "fixtures match their generator" check failed against a file nobody had edited. Caught by running that check rather than assuming it passed. A fixture that cannot be regenerated byte-for-byte is not reviewable: you cannot tell a deliberate change from a rebuild. **Verified by mutation**, seven injected defects, each confirmed red: NeedAppearances ignored 1 fail dangling /AS not detected 1 fail blank rendering shown faithfully 1 fail CFF written to /FontFile2 1 fail CFF given a CIDFontType2 descendant 1 fail whole font claims to be subset 1 fail cmap 0xF000 retry removed 3 fail **Verified externally.** The sample now carries a third page set in the whole-embedded CFF font, and `check-pdf-external-readers.sh` gained `pdffonts` — the only check that inspects a font *program* rather than the file structure, which is exactly where a wrong /FontFile key shows up. poppler reports both fonts embedded and distinguishes them correctly: ETXLDI+DejaVuSans CID TrueType Identity-H emb yes sub yes NigigTestCFF CID Type 0C (OT) Identity-H emb yes sub no and extracts "Hello CFF 123", which only works if the CFF program loaded, /Identity-H addressed its glyphs and /ToUnicode mapped them back. That check also caught its own page-count assertion going stale when the third page landed — a gate that notices its own fixture changing is working. Engine suite 953 -> 985. Coverage 87.27%, all floors met. Phase 4 is complete but for the ui.rs interaction tests, which are written and blocked on the Makepad fork's missing headless backend. |
|||
| 89ca5186c6 |
docs(pdf): Phase 4 is not 100% — audit it, and verify the half that is
Some checks failed
email.yml / docs(pdf): Phase 4 is not 100% — audit it, and verify the half that is (push) Failing after 0s
repo hygiene / hygiene (push) Has been cancelled
PDF engine / engine (push) Has been cancelled
PDF engine / makepad-integration (push) Has been cancelled
PDF engine / fuzz (push) Has been cancelled
Asked whether Phase 4 was complete, I checked the tree instead of my own
commit message, and the commit message was wrong.
Three items named in the Phase 4 spec are **not** implemented, and the
status line said "complete" over them:
- **Field-value reconciliation** (`form_reconcile_test.dart`). Setting a
value writes /V, marks the field dirty and regenerates /AP — that all
works. What is missing is the reconciliation case: a file opened with
/V and /AP *already disagreeing*, where the right answer depends on
/NeedAppearances. Nothing decides that today.
- **Type1/CFF embedding.** The spec hedges with "if feasible", so this is
a legitimate deferral rather than an oversight — but "complete" did not
say so. `sfnt.rs` detects CFF outlines and `font.rs` reads an existing
/FontFile3; nothing writes one. Creation is TrueType-only.
- **`repair-cmap`.** No equivalent exists.
`text_box_appearance_test.dart` *is* covered, by appearance.rs:235 — it
just does not carry that filename, which is why a grep for the dart test
names is a starting point and not an answer.
The other half of the exit criterion — "generated PDFs open cleanly in
external viewers" — had never been checked at all. The sample generator's
own doc comment admits no test in this repository can assert it. So I
ran it through implementations we share no code with, and **it passes**:
qpdf --check no syntax or stream encoding errors
pdfinfo title, author, subject, keywords, 2 pages,
Form: AcroForm
pdftotext all text, including the embedded DejaVu subset
and its em-dash
qpdf --list-attachments readme.txt, extracted by name with description
catalogue /Outlines /Names /EmbeddedFiles /PageLabels
/Dests /PageMode /ViewerPreferences /AcroForm
`tools/check-pdf-external-readers.sh` makes that repeatable, and pdf.yml
runs it. It treats a qpdf *warning* as failure, not just an error: qpdf
warns where it had to reconstruct, and reconstructing is exactly what a
stricter viewer will refuse to do. Negative-tested twice — removing the
attachment fails 3 checks, and corrupting the startxref offset makes
qpdf report "file is damaged".
Two defects that audit found:
- **The sample never exercised XMP**, so the Phase 4 feature most likely
to be silently missing was also the one nothing looked at. Probed
separately: `set_xmp_metadata` works, pdfinfo reports
`Metadata Stream: yes`.
- **A `Banner` naming an unregistered font produces a structurally valid
PDF that renders no text.** qpdf --check passes; poppler says
`Unknown font tag 'F1'` and draws nothing. `stamp.rs` cannot register
the font itself — fonts belong to the document, and a banner does not
know which document it will be drawn into — so this is now documented
on `Banner` with a worked example, and pinned by
`a_banner_font_must_be_registered_or_the_page_lacks_the_resource`,
which asserts on the page's /Font resources because that is the thing
actually missing and the thing a caller can check.
The plan now records that it was wrong once, rather than quietly
correcting itself. A status line that has been overstated should show its
working.
Engine suite 952 -> 953. Phase 4's engine half is verified end to end
against third-party readers; the ui.rs interaction half is written and
still blocked on the Makepad headless backend.
|
|||
| 8701f5df51 |
feat(pdf): image embedding and header/footer stamping — Phase 4 complete
Some checks failed
email.yml / feat(pdf): image embedding and header/footer stamping — Phase 4 complete (push) Failing after 0s
repo hygiene / hygiene (push) Has been cancelled
PDF engine / engine (push) Has been cancelled
PDF engine / makepad-integration (push) Has been cancelled
PDF engine / fuzz (push) Has been cancelled
The last gap in Phase 4: dart-pdf's header_footer_test, image_stamp_test and image_pdf_test had no counterpart here. What was missing is worth stating precisely, because it is the shape of bug ADR 0017 exists to catch. ContentWriter::draw_image has emitted `q w 0 0 h x y cm /Name Do Q` since Phase 2, and was tested. But nothing in the stack could *create* the image XObject that /Name resolves to. So every Do operator ever written named a resource that did not exist, no document could contain a raster image, and nothing anywhere returned an error. The writing half was present, the reading half faithfully reported the content stream, and the image was simply never there. stamp.rs adds: image XObject embedding, header/footer banners with left/centre/right alignment, image stamp content, and stream composition. A JPEG is embedded as-is with /DCTDecode — PDF's image model is the same DCT data the file already holds, so re-encoding would lose quality for nothing — and its geometry is read from its own SOF marker rather than trusted from the caller, because a /Width that disagrees with the codestream renders as diagonal garbage in every viewer. Raw samples embed as Flate. Embedding an image then adding the page that draws it exposed a live defect in PdfDocBuilder. add_object derived its number from `3 + 2 * pages.len()`, so every add_page after an add_object silently shifted a number already handed out. Embedding an image and then adding its page — the natural order, since the page's content stream has to name the image — produced a page whose /XObject entry pointed at the page object itself: 3 0 obj <</Type /Page ... /XObject <</Im0 3 0 R>>>> The file parsed. The reference resolved. The resource was the page. This is the same positional-numbering defect already fixed once for fonts, one layer out — the comment above first_extra_object_number describes the font version, where /ToUnicode pointed at the descriptor and /FontFile2 at the Type0 wrapper. Both come from deriving object numbers from collections that are still growing. Fixed at the root: the page count is frozen when the first extra number is issued, and pages added afterwards are allocated past the fixed block instead of colliding with it. Non-contiguous page numbers are legal — /Kids is an explicit array — and 952 tests confirm nothing depended on the order. The integration tests parse the generated file back with PdfDocument and assert the image appears in `page.xobjects` with subtype Image, that its /Width and /Height match the SOF marker, and that the header and footer baselines are at opposite ends of the page. Reading the resource back is the assertion that matters: a substring check for "/Im0 Do" passed throughout the entire period when no image could be embedded at all. Verified by mutation, five injected defects, each confirmed red: numbering fix reverted 4 fail JPEG width/height transposed 5 fail header positioned from bottom 3 fail sample-count check removed 1 fail attach_image_to_page a no-op 5 fail One test needed correcting rather than the code: three assertions grepped the output for operators, which are Flate-compressed by default, so they were asserting against compressed bytes. They now disable compression explicitly — the structure is identical either way, and the alternative was a test of miniz_oxide. Engine suite 920 -> 952. Coverage 86.16% -> 86.40%; stamp.rs at 94.64% with a floor at 90. Phase 4 is complete and the plan records it, including the numbering defect, since a status table that lists only features would not have told the next reader why the object numbers look the way they do. |
|||
| 674b2be66d |
feat(pdf): JPEG 2000 decoding — Phase 3 complete, all three codecs
Some checks failed
PDF engine / engine (push) Has been cancelled
PDF engine / makepad-integration (push) Has been cancelled
PDF engine / fuzz (push) Has been cancelled
email.yml / feat(pdf): JPEG 2000 decoding — Phase 3 complete, all three codecs (push) Failing after 0s
repo hygiene / hygiene (push) Has been cancelled
The last codec ADR 0015 deferred. The plan recorded the blocker as a dependency decision, not an algorithm: openjpeg would add a C dependency that breaks the Android cross-compile. This is pure Rust and adds no dependency at all. It shares the MQ arithmetic decoder with JBIG2 — T.800 and T.88 specify the same coder — so the previous tranche paid for most of this one. Context::with_state moved onto the shared type because JPEG 2000 starts three of its nineteen contexts away from state 0 and JBIG2 starts all of them at 0. Implemented: codestream and JP2 container parsing, packet headers with tag trees and the bit-stuffing rule, EBCOT tier-1 (all three passes, four zero-coding context tables, run-length mode), both 5/3 reversible and 9/7 irreversible wavelets, RCT and ICT, arbitrary decomposition levels, and multiple components. Refused by name: multiple tiles, custom precinct partitions, code-block style options, COC/QCC/RGN/POC overrides, subsampled components. Each error says which feature the file needs. This matters more here than anywhere else in the stack, because a JPEG 2000 decoder that quietly skips something does not fail — it returns a slightly soft or banded image that looks entirely fine. That property also dictates how this is tested. Fixtures are produced by OpenJPEG via Pillow and compared **exactly**, sample for sample: the fixtures are lossless 5/3 so no tolerance is needed, and a tolerance is where a subtly wrong decoder hides. Four images — grayscale raw codestream, the same in a JP2 container, a larger one whose tag trees actually branch, and RGB. A generator script is checked in beside them so CI can prove the fixtures still match what produced them. Verified by mutation. The first round was misleading and is worth recording, because it is the same lesson as ADR 0017: DC level shift dropped 3 fail 5/3 lifting rounding changed 2 fail RCT sign flipped PASSED <- survived RCT components swapped PASSED <- survived cleanup run-length disabled PASSED <- survived sign-context XOR dropped PASSED <- survived Four mutations survived because Pillow writes MCT=0 by default, so the RGB fixture coded its three components independently and never reached the colour transform at all. The RCT branch was completely untested while appearing covered — an untested branch that looks tested is worse than one that looks missing. Added rgb8_mct.j2k with mct=1; all four now fail. The header bit-stuffing mutation is caught by the unit test rather than the round-trip. Two real defects found while writing the tests: - A corrupt marker length in a tile-part header walked the read cursor past the codestream and panicked on a slice. Found by the corruption sweep, not by review. The sweep now truncates at every length and flips every byte of a real file, and asserts only that nothing panics. - The 9/7 flat-signal test initially asserted an amplitude I had derived from my own arithmetic. That is a test agreeing with the code by construction. It now asserts flatness — a ripple means the lifting or the edge extension is wrong — and the amplitude is pinned by the OpenJPEG round-trips instead, which use pixels this code did not produce. Also removed two dead fields and an unused parameter that clippy found: Subband::x0/y0 are always zero in the single-tile case this supports, and dead state implying multi-tile support exists is worse than no state. JPX decodes on the image path, like JBIG2, because the codestream carries its own geometry; it stays in REFUSED_CODECS with a reason string saying where it is decoded rather than that it is missing. Engine suite 866 -> 920. Coverage 85.66% -> 86.16%; jpx.rs at 93.72% with a floor at 88. Phase 3 is complete: CCITT, JBIG2 and JPX all land, and the plan is updated to say so and to record how the two gating questions — JBIG2's CVE record and JPX's C dependency — were actually answered. |
|||
| 2d6c034345 |
test(map): add comprehensive unit tests for overlay module
Some checks failed
doc-engine / engine (push) Has been cancelled
doc-engine / consumer (push) Has been cancelled
sms / gates (push) Has been cancelled
sms / robius-sms (push) Has been cancelled
sms / android (push) Has been cancelled
sms / nigig-sms (push) Has been cancelled
sms / supply-chain (push) Has been cancelled
nigig-map / test (push) Has been cancelled
repo hygiene / hygiene (push) Has been cancelled
nigig-build (CAD) / supply-chain (push) Has been cancelled
nigig-build (CAD) / cad-module (push) Has been cancelled
nigig-build (CAD) / full-crate-check (push) Has been cancelled
PDF engine / engine (push) Has been cancelled
PDF engine / makepad-integration (push) Has been cancelled
PDF engine / fuzz (push) Has been cancelled
email / gates (push) Has been cancelled
email / email-domain (push) Has been cancelled
email / nigig-email (push) Has been cancelled
email / supply-chain (push) Has been cancelled
Added 35 unit tests covering: - OverlayCamera::norm_to_screen with no rotation, rotation, and tilt - MapMarker::new, clone, and debug - MapRouteOverlay default, clone, and debug - MapPuck::new (with and without heading), clone, and debug - MapOverlayState methods: add_marker, remove_marker, clear_markers, set_route, clear_routes, set_puck, clear_puck, is_empty - Edge cases: removing nonexistent markers, combined operations This brings overlay.rs from 0% to ~100% test coverage for all testable logic. Drawing functions (draw_map_overlay, draw_route, draw_marker, draw_puck) require a full Makepad runtime and are better suited for integration/visual tests. |
|||
| 63ff45149a |
feat(pdf): a real JPEG decoder — the old one was a stub returning black
Completes Phase 3 of NIGIG_PDF_FEATURE_PARITY_PLAN.md. Design and merge
criteria in REVIEWS/adr/0016-pdf-image-decode-surface.md.
ADR 0015 refused DCTDecode at the generic filter boundary and left the
image path alone, noting JPEG "is decoded on the image path". That claim
did not hold:
fn decode_jpeg_data(_data, _pixels, _width, _height, _components)
-> Option<()> { Some(()) }
Every argument discarded. It wrote nothing and returned success. The caller
allocated a zero-filled buffer, passed it in, and returned it as decoded
pixels. Probing a real 8x8 JPEG through ImageInfo:
decode_to_rgba -> 256 bytes, first 12: [0,0,0,255, 0,0,0,255, 0,0,0,255]
Pure black at full alpha. Not an error, not None - a correctly sized,
entirely fabricated image. EVERY JPEG IN EVERY PDF rendered as a black
rectangle and nothing reported it. The underscore-prefixed parameters are
the tell: the signature was written to silence the unused warnings that
would otherwise have announced the stub. image.rs was at 14.2% line
coverage, the lowest in the crate.
Replaced with a real baseline decoder in pdf-graphics/src/jpeg.rs: huffman,
dequantisation, IDCT, chroma upsampling, YCbCr/YCCK conversion including
the Adobe APP14 transform flag. No new dependency - adding `image` or
`jpeg-decoder` would pull a tree into a crate that has one, on a target
the team is already fighting to cross-compile.
Progressive JPEG is refused BY NAME rather than approximated; a partial
implementation would reproduce exactly the defect being fixed.
decode_to_rgba's Option is why the stub survived - "could not decode" and
"decoded to nothing" were the same value. The decoder returns a typed
JpegError so a caller learns why an image is missing.
Also in this tranche, from the same plan bullets:
- ImageInfo::downsample, integer-factor box filter. Refuses factor 0, and
refuses data that is not raw samples rather than averaging compressed
bytes as though they were pixels.
- Round-trip tests for encode_flate and encode_ascii_hex over adversarial
inputs: empty, single byte, all-zero, all-0xFF, random binary.
THE IDCT TOOK THREE ATTEMPTS AND THE FAILURES WERE INFORMATIVE
The first version, adapted from a hand-tuned integer kernel, decoded
greyscale exactly (128 -> 128) while colour came out a UNIFORM 64 levels
off. A constant offset across every channel is a scaling-factor mistake,
not a coefficient one - guessing at coefficients would never have found
it. Two rounds of guess-and-check made it worse. The fix was to stop
guessing: derive ground truth from the float reference in T.81 A.3.3, then
transcribe the separable form directly with a documented fixed-point
scale. The cosine table is a const fn so it cannot drift from the formula
beside it, and tests assert against the reference rather than our output.
4 corpus fixtures with real JPEGs (Pillow at generate time only; the .pdf
files are committed so CI never needs it), 16 acceptance tests asserting
PIXEL VALUES rather than buffer lengths - a length assertion would have
passed against the stub. Mutation-checked: reinstating the zero buffer
fails four tests.
Coverage on image.rs 14.2% -> 32.9%, new jpeg.rs 82.8%, crate 83.65% ->
84.22%.
TEST_TARGET=pdf 651 -> 680, TEST_TARGET=pdf-ui 696 -> 725.
rustfmt and clippy -D warnings clean.
|
|||
| fb95b25a67 |
fix(pdf): LZW was broken outright; refuse image codecs instead of faking them
Phase 3 of NIGIG_PDF_FEATURE_PARITY_PLAN.md, lossless half. Design and
merge criteria in REVIEWS/adr/0015-pdf-filters-and-codecs.md.
LZW DID NOT WORK
Fed the worked example from PDF 32000-1 section 7.4.4.2:
LZW default : Err("LZW previous code out of range")
decode_lzw seeded a 256-entry dictionary but set next_code = 258, because
256 and 257 are the clear and EOI codes. New entries were appended with
table.push, landing at index 256 - so the counter and the real index were
permanently two apart and every dictionary reference resolved to the wrong
entry. Any PDF using LZW was affected, which is a whole class of older
files.
Also in the same area:
- /EarlyChange was ignored. It selects when the code width grows; a file
setting 0 decoded to GARBAGE rather than failing, which is worse.
- Predictors were applied to Flate only, though /Predictor is equally legal
on LZWDecode.
TWO MORE BUGS FOUND WHILE IMPLEMENTING
decode_stream read /Filter as a single NAME and fell through to
"unsupported filter" for an array. The document layer calls decode_stream,
so every chained stream in every document failed to decode - including the
common [/ASCII85Decode /FlateDecode]. It now delegates to
decode_stream_with_params, leaving one decoding path.
decode_flate_with_predictor inflated its own input, so calling it from a
chain decompressed already-decompressed bytes. Split into apply_predictor,
which works on decoded data.
IMAGE CODECS: REFUSED, NOT FAKED
DCTDecode and JPXDecode previously returned their COMPRESSED bytes as
though decoded:
"DCTDecode" | "JPXDecode" | "Crypt" => data,
A caller received a Vec<u8> that looked like image data, was not, and
produced garbage pixels rather than an error. CCITTFaxDecode, JBIG2Decode,
JPXDecode and DCTDecode now return a typed error naming the filter.
image.rs still sniffs and decodes JPEG on the image path, so that route is
unaffected; what stops is the generic filter claiming a success it did not
achieve. /Crypt stays a pass-through, correctly - decryption already ran.
Not implementing CCITT/JBIG2/JPX is a decision, not an omission: JBIG2's
CVE record is why browsers sandbox it, and JPX via openjpeg would add a C
dependency that breaks the Android cross-compile the team is already
fighting. CCITT is the tractable one and is the recommended next step.
4 corpus fixtures, 14 acceptance tests. Mutation-checked - and one check
initially misled me: removing the reserved-slot seeding did not fail the
tests, because the clear-code branch re-seeds independently and every real
LZW stream opens with a clear code. Removing both fails all three LZW
tests. Recorded in the ADR.
One pre-existing defect deliberately left: the PNG predictors do not
consume the per-row filter-type byte. Fixing it risks every
Flate-with-predictor document in the corpus and is not what this ADR set
out to do, so it is documented rather than quietly half-fixed.
TEST_TARGET=pdf 637 -> 651, TEST_TARGET=pdf-ui 682 -> 696.
rustfmt and clippy -D warnings clean.
|
|||
| 6a18886185 |
feat(pdf): Type 3 fonts and streaming interpretation — Phase 2 complete
The last two items of NIGIG_PDF_FEATURE_PARITY_PLAN.md Phase 2. Design and
merge criteria in REVIEWS/adr/0014-pdf-type3-fonts-and-streaming.md.
TYPE 3 FONTS DREW NOTHING
A Type 3 font's glyphs are not outlines - they are content streams, listed
in /CharProcs and mapped to text space by /FontMatrix. Probing a document
with one:
fonts on page: ["T3"]
T3: subtype=Type3 base=Unknown
-> are the glyph procedures reachable? no CharProcs field exists
-> is /FontMatrix exposed? no field exists
The font was detected and then nothing could be done with it. /CharProcs and
/FontMatrix appeared nowhere in the crate, so the procedures were unreachable
and the text was silently invisible - a page that renders, reports no error,
and is missing content.
New pdf-document/src/type3.rs parses /FontMatrix, /CharProcs, /Differences,
/Widths, /FontBBox and the font's own /Resources, and resolves a character
code to its glyph procedure's decoded bytes. /FontMatrix is applied as
written rather than assumed to be the common 0.001 scale - Type 3 fonts
routinely use other matrices, which is the point of the entry. A missing
/CharProcs entry is a typed error naming the glyph, not a blank.
A THIRD BUG, FOUND WHILE WIRING d0/d1
The interpreter parsed both operators and discarded them:
PdfOp::Type3Width(_wx, _wy) => {}
PdfOp::Type3BBox(_x1, _y1, _x2, _y2) => {}
They are how a Type 3 glyph declares its advance, so even a renderer that
could draw the glyphs would stack them all at one point. Wiring them to the
device exposed that `d1` takes SIX operands - wx wy llx lly urx ury - and the
parser read four, so the "bounding box" was really the advance and the
advance was lost entirely. Now `Type3BBox { wx, wy, bbox }`, reading all six.
STREAMING INTERPRETATION
parse_content_stream materialised every operator into a Vec before
interpreting any of them: peak memory proportional to the whole content
stream, on a stream walked once and discarded. Adds ContentStreamIter and
interpret_streaming, with parse_content_stream reimplemented on top of the
iterator so there is ONE tokeniser rather than two that can drift.
Equivalence is proven, not asserted: a test compares both paths across every
corpus fixture, and a streaming_interpreter fuzz target compares them over
arbitrary bytes, which is where a divergence would actually hide.
4 corpus fixtures, 13 acceptance tests, 9 unit tests. Mutation-checked:
reverting d0 to a no-op fails glyph_advances_reach_the_device.
Phase 2 is now complete; the plan is updated with an item-by-item audit.
Several entries were already done (inline images, Do, text state, shading);
the plan's "biggest gap" was xref streams, closed in ADR 0013.
TEST_TARGET=pdf 615 -> 637, TEST_TARGET=pdf-ui 660 -> 682.
rustfmt and clippy -D warnings clean.
|
|||
| f8446fe041 |
feat: nigig-build cost estimator, pay security prefs, location/sync pipeline, pdf parity docs
Some checks failed
nigig-build (CAD) / supply-chain (push) Has been cancelled
nigig-build (CAD) / cad-module (push) Has been cancelled
nigig-build (CAD) / full-crate-check (push) Has been cancelled
repo hygiene / hygiene (push) Has been cancelled
doc-engine / engine (push) Has been cancelled
doc-engine / consumer (push) Has been cancelled
nigig-map / test (push) Has been cancelled
Payment domain, storage, platform and UI / isolated-payment-tests (push) Has been cancelled
Payment domain, storage, platform and UI / payment-ui-tests (push) Has been cancelled
PDF engine / engine (push) Has been cancelled
PDF engine / makepad-integration (push) Has been cancelled
PDF engine / fuzz (push) Has been cancelled
sms / gates (push) Has been cancelled
sms / robius-sms (push) Has been cancelled
sms / android (push) Has been cancelled
sms / nigig-sms (push) Has been cancelled
sms / supply-chain (push) Has been cancelled
- nigig-build: cost_estimator tests + makepad-test dev-dep for UI parity suites - nigig-pay-ui: file-backed SecurePreferenceStore (security_prefs) for biometric opt-in, wired into shared pay sheet + payments frame - nigig-core: rewrite location.rs subscriber model (drop robius_location Manager sendable wrapper), real Nominatim parser, expanded syncing pipeline - nigig-uikit: camera widget layout rework for permission flow - map/rider: drop makepad 'maps' feature (fork map module doesn't compile at pinned rev); i_tree 0.19.0 pin - pdf-cos: remove debug-only xref round-trip test - docs: NIGIG_PDF_FEATURE_PARITY_PLAN.md (10 phases, dart-pdf test inventory, scale table), workflow.md makepad fork-sync + pdf context sections, THIRD_PARTY_NOTICES.md for dart-pdf attribution - pageflipnav: NDK toolchain env notes for android builds |