mirror of
https://github.com/firecrawl/anydoc.git
synced 2026-09-14 14:18:33 +08:00
c86ab568a7
* plan: approved architectural refactor plan (revision 3) * test: fixture corpus, generator, and snapshot baseline harness (P0) * feat: typed ConvertError public API and log facade (P1.1, P1.2) * feat: information-preserving model, canonical grid, ported markdown renderer (P2, P5) * feat: namespace-aware package layer with fixed limits (P3) * feat: shared resolution utilities - deltas, chains, list identity, fields (P4) * feat(sheet,csv): migrate to model - no header promotion, durations, lossless floats, encoding+sniffing fixes (P6) * fix: drop dead legacy helper, clean stray snapshot artifact * feat(docx): full resolution frontend - styles, numbering, assets, anchors, rich objects (P7) * test: detonating zip-bomb fixture; check off P7 in plan * feat(odf): grid tables, typed values, delta styles, groups, notes, assets (P8) * feat(pptx): layout/master/default text cascade, notes, assets, graphicframes (P9) * chore: drop unused legacy read_rels * feat(epub): chapter-scoped anchors, css subset, grid tables, assets (P10) * feat(rtf): fallible bin-aware lexer, typed list/style/font tables, nested tables (P11) * fix: clippy in rtf lexer/tables * feat(doc): spec-order resolution - full STSH, Prm/Prc, list tables, CP-correct text (P12) * chore: trim legacy support to remaining ppt frontend, drop stray snapshot * feat(ppt): style/master text atoms, notes, labelled recovery; delete legacy layer (P13, P14.1) * docs: readme, changelog, 0.2.0; test: fuzz targets, mutation harness, abuse assertions (P14, P15) * plan: reopen with post-review findings R1-R23; restore scope log (R21) * fix: R4 startOverride ordering, R9 render all anchors, R19/R20 grid invariant + private renderer * fix(docx): correct toggle-property semantics per ECMA-376 17.7.3 (R3) Bold/italic/strike from the style hierarchy are toggle properties: each true specification along the basedOn chain flips the value inherited from docDefaults, and a false specification is a no-op. Direct run formatting stays absolute. on_off now accepts the full ST_OnOff value space including on/off. StyleChains loses its delta resolver in favour of a generic cycle-checked walk shared by the DOCX probes. Fixture cases: style-false no-op, character-style true toggle, double-toggle chain cancellation, direct off/on. Sample snapshots also pick up the R9 all-anchors change from the previous commit. * chore: drop stray .snap.new artifact * docs(plan): check off R3, R4, R9, R19, R20 * fix: propagate ResourceLimit out of optional-part recovery paths (R2) read_rels, the docx/pptx rel_part helpers, odf load_image, and the EPUB image loader previously logged and skipped every failure. Fixed safety limits are hard errors in every context, so those helpers now return Result and re-raise ResourceLimit while still degrading gracefully on missing, unresolvable, or corrupt parts. The HTML walker carries the first fatal error through its infallible walk and surfaces it from to_blocks. Chart/diagram part parsing propagates limit errors too. Abuse fixture: imagebomb--errors.docx puts a 192 MiB payload behind an image relationship; conversion must fail with ResourceLimit instead of skipping the image. * docs(plan): check off R2 * feat(ooxml): Strict namespace family and rels-driven part discovery (R1) ISO 29500 Strict re-roots the Transitional namespaces and relationship types under http://purl.oclc.org/ooxml/; both are normalized onto the Transitional constants when XML and rels parts are parsed, so Strict packages flow through the same code paths. Part discovery now follows OPC relationships instead of conventional paths: the main part comes from the package-level officeDocument relationship, and DOCX styles/numbering/footnotes/endnotes come from the main part''s typed relationships (conventional siblings remain the fallback). PPTX resolves the presentation part and its slides the same way. Fixtures: handmade-strict.docx/.pptx (full Strict family) and handmade-altpath.docx/.pptx (no part at a conventional path), plus a normalization unit test. * docs(plan): check off R1 * fix(odf): preserve internal gap coordinates, elide only trailing filler (R5) Empty row/column runs inside the used range now materialize in full - charged against the fixed expansion budget so pathological repeats hit ResourceLimit instead of memory - and only trailing filler is elided. The old 100-row/1000-cell compression caps are gone, as is the covered- cell run cap (also budget-charged now). The table renderer keeps interior empty grid rows as blank table rows (they carry the source coordinates); trailing blank rows are still popped. Fixture: handmade-gaps.ods with content after a 120-row and a 1010-cell gap plus trailing runs that must stay elided. Real-sample spreadsheets pick up their interior blank rows (small size deltas only). * docs(plan): check off R5; drop stray .snap.new artifact * fix(ppt): bound binary record traversal with fixed limits (R6) The container walk now runs over an explicit stack with a fixed nesting depth cap and a total record-count cap (documented in package::limits); crossing either hard-fails with ResourceLimit instead of exhausting the call stack on crafted nesting. The persist-directory resolution moved into a free locate_document helper so the slide traversal can carry Result end to end. gen_fixtures gains a minimal CFB v3 writer for handmade OLE2 fixtures; abuse fixture deepnest--errors.ppt nests 200 containers and must fail with ResourceLimit. * docs(plan): check off R6 * fix(doc): decode compressed text via the FIB language id (R7) Compressed (8-bit) piece text previously assumed Windows-1252. The code page now comes from FibBase.lid - or FibRgW97.lidFE when fFarEast is set - mapped through the MS-LCID primary language (Shift-JIS, GBK/Big5, EUC-KR, and the single-byte Windows code pages). Decoding is byte-accurate: each CP is one byte in a compressed piece, so a double-byte character occupies two CPs and anchors its FC at the lead byte, keeping CP-indexed PLC structures and FC-indexed FKP runs aligned. Fixtures: handmade-shiftjis.doc (fFarEast + lidFE, multibyte) and handmade-cyrillic.doc (lid 0x0419), built with the new CFB writer. * docs(plan): check off R7 * fix(pptx): titles in shape order; extract p:oleObj payloads (R8) Title placeholders keep heading semantics but no longer float to the front of the slide - all shapes emit in spTree order. graphicFrames holding p:oleObj now retain the embedded payload in the asset store (identity from name/progId as alt, application/vnd.ms-ole-object media type), matching the DOCX OLE handling. Fixture: handmade-order.pptx with a body shape before the title and an OLE graphicFrame; a harness test asserts the payload bytes land in Document::assets. * docs(plan): check off R8 * chore: gitignore insta .snap.new artifacts; drop stray file * fix(epub): per-chapter CSS cascades and spine-only anchors (R10, R11) Each chapter now builds its own CSS cascade from the stylesheets it links plus its inline <style> blocks, in document order (stylesheet parts cached across chapters) - replacing the single combined sheet that merged every manifest CSS item in nondeterministic HashMap order. Intra-book links classify as Anchor only when they target a converted (linear, XHTML) spine document; links to images, downloadable files, or non-linear resources stay Relative instead of becoming dead anchors. Fixture: handmade-css-links.epub with conflicting stylesheets across three chapters, a chapter <style> block, and links to an image, a binary file, a non-linear appendix, and a spine chapter. * docs(plan): check off R10, R11 * feat(model): preserve Roman/alphabetic list markers end to end (R12) MarkerKind moves into the model: List carries the marker family instead of a bare ordered flag, and every frontend supplies its real marker (HTML ol type=, ODF num-format, OOXML numFmt, RTF levelnfc, DOC nfc, PPT buAutoNum - all already resolved to MarkerKind). The renderer emits decimal lists as GFM ordered lists and Roman/alphabetic levels as bullets carrying the formatted source marker as literal text ("- iv. ..."), since GFM has no non-decimal list syntax; table cells use the bare label. Bijective-base-26 alphabetic and Roman formatting live on MarkerKind::label with unit coverage; snapshots across DOCX, ODT, RTF, EPUB, and PPTX now show the actual source marker types. Also checks off R12 in REFACTOR_PLAN.md. * fix(rtf): \sbasedon chains and boundary-matched table merges (R13) Stylesheet styles now resolve their \sbasedon chains (child settings win, \sbasedon222 is the null base, cycles resolve from the acyclic prefix with a log), so a style carrying only an id still inherits bold and outline level from its base. Tables keep each cell's \cellx boundary: grid columns come from the clustered union of every row's boundaries, so nonuniform rows align by actual position instead of cell ordinal. Vertical merges are explicit - a chain starts at \clvmgf and \clvmrg continues it only when the cell's column range matches; a stray continuation stays visible. Fixture: handmade-merge.rtf (inherited style chain + a nonuniform merged table + stray \clvmrg). LO's text.rtf merged header now renders its covered position in place. * docs(plan): check off R13 * fix(ppt): per-slide masters with tri-state level defaults (R14) TextCFException style bits are per-bit tri-state: a bit is specified only when its own mask bit is set, so unmasked bits no longer read as absolute formatting; the bulletFlags fHasBullet value likewise applies only under masks.hasBullet. TxMasterStyleAtom levels now carry tri-state bullet/bold/italic defaults, and character runs resolve over the active master's per-level defaults (including shapes with no StyleTextPropAtom at all). Masters are collected in MasterListWithText order (persistIdRef + masterId from each MasterPersistAtom) and each slide selects its master through SlideAtom.masterIdRef, with the first listed master as the deterministic fallback; a persist-directory scan in stable offset order covers decks whose master list is unusable. Fixture: handmade-multimaster.ppt - two masters with opposite bullet and bold/italic defaults, two slides referencing them by masterIdRef. * docs(plan): check off R14 * fix(docx): gridBefore/gridAfter, legacy hMerge, ST_OnOff tblHeader (R15) trPr gridBefore/gridAfter filler materializes as empty cells so every cell (and every vertical-merge chain) keeps its grid column. Legacy hMerge continuation cells fold into the preceding origin, widening its span and retaining their content. tblHeader now goes through the ST_OnOff parser, so an explicit false value no longer marks a header row. Fixture: handmade-tables.docx covering all three: a header row, a tblHeader w:val="0" row whose vMerge continuation lands on the right column via gridBefore, and a legacy hMerge pair with gridAfter filler. * docs(plan): check off R15 * fix(odf): default-styles beneath named chains; full ISO durations (R16) style:default-style now sits at the base of every named chain of its family and applies to unstyled content too (an unstyled paragraph resolves through the same path). ISO durations handle date components: P1DT2H is 26 hours, weeks are seven days; year/month components have no fixed length so those durations keep their raw ISO text. Fixtures: handmade-defaults.odt (default-style bold inherited by named and unstyled paragraphs, cleared by an explicit normal weight) and handmade-durations.ods (P1DT2H, PT26H30M15S, P2W, -PT1H5M, P1M). * docs(plan): check off R16 * feat(sheet): merged ranges as spanning cells; untrimmed strings (R17a) Merged regions from the calamine fork (xlsx via load_merged_regions, xls via BIFF MERGEDCELLS) map onto the canonical grid: the top-left cell becomes a spanning origin and the other positions are covered. String cells keep their leading/trailing whitespace - a cell's padding is source content. R17b (number-format rendering) and R17c (hyperlinks) remain open: the fork exposes no public API for either; recorded in the scope log. Fixture: handmade-merged.xlsx (horizontal + vertical merges, padded inline string) plus a unit test for untrimmed strings. * fix(package): size the XML node cap from measured DOM cost (R22) A counting allocator in the unit-test binary measures the DOM's worst-case per-node heap cost (~376 bytes for a minimal element with one attribute). At the old 20M-node cap that projected to ~7.5 GB, an order of magnitude past the archive budget the limit is meant to protect. The cap drops to 2M nodes (~750 MB worst case), and a regression test projects the measured per-node cost to the cap and asserts it stays under 1 GiB. No corpus fixture or real-world sample comes near the new cap. * docs(plan): check off R22 * fix(render): differential-test the heading slugger vs github-slugger (R23) gfm_slug now matches GitHub's reference slugger on a committed golden corpus: full-Unicode lowercase (multi-character expansions kept, e.g. dotted capital I) and combining marks retained (the reference keeps Unicode marks; is_alphanumeric misses the blocks outside Other_Alphabetic). The corpus covers punctuation stripping, NBSP and curly quotes, precomposed vs decomposed accents, Cyrillic/CJK/RTL scripts, emoji removal, and the duplicate-suffix convention, run as one stateful pass. One deliberate divergence stays: an empty slug becomes "section" so the anchor remains linkable. Goldens regenerate via bench/js/gen_slugger_goldens.mjs (github-slugger added to bench dependencies). * docs(plan): check off R23 * docs(plan): Phase 16 status - all findings closed except R18 (deferred) and open R17b/c * chore(ci): update checkout action version * docs(plan): record round-3 review findings as Phase 17 (S1-S20) * fix(model): charge the complete span/repeat area before table expansion (S1) GridBuilder::place is now fallible: the full col_span*row_span area is charged against MAX_EXPANSION before any pending-position work, so a tiny document declaring an enormous span errors with ResourceLimit instead of performing billions of insertions. ODF row repeats whose cells are all empty/trailing are charged up front (they previously looped uncharged), HTML spans are clamped to the spec maxima (colspan 1000, rowspan 65534), and spreadsheet merged regions are clamped to the used range instead of fixed 10k/100k caps. RTF gained a deferred-error slot so table assembly can surface the limit. Abuse fixtures: hugespan--errors.{ods,pptx}, emptyrowrepeat--errors.ods. * docs(plan): check off S1 * fix(docx): outline level as a tri-state property with direct override (S2) Resolve outlineLvl through the style chain with the explicit off value (9) stopping inheritance, then overlay direct paragraph properties: a direct outlineLvl now overrides a style heading, and a direct 9 turns a style-inherited heading off instead of being silently discarded. Fixture: handmade-outline.docx covers each combination. * docs(plan): check off S2 * fix(docx): resolve style-based numbering levels via w:pStyle bindings (S3) Per ECMA-376 $17.3.1.19 the ilvl inside a paragraph style's numPr is ignored; the effective level is the abstract level whose w:pStyle matches a style along the paragraph's basedOn chain (child first, level 0 when none matches). Abstract levels now retain their pStyle bindings, and lvlOverride's nested w:lvl updates them. Fixture: pStyle-bound levels in handmade-numbering.docx with deliberately wrong style ilvl values. * docs(plan): check off S3 * fix(doc): implement the published binary list numbering algorithm (S4) Counters are keyed by list identity (lsid) so every LFO referencing the same list continues one sequence; LFOLVL start-at overrides restart the shared sequence when their LFO is first used; levels retain fNoRestart/ilvlRestartLim and deeper levels restart per their own rule; and the LVL number text (xst + rgbxchNums placeholders, with fLegal) is preserved, rendering composite markers such as 1-a) literally. The model gained a per-item marker_label carried through shared list assembly and both renderers. Unit tests cover the LVL byte layout and every counter rule; the text.doc snapshot now shows the source's actual a) markers. * docs(plan): check off S4 * fix(docx): extract OLE objects before their VML preview images (S5) Standard Word OLE markup carries a v:imagedata preview next to the o:OLEObject; the image branch fired first and discarded the object's identity and payload. The OLE branch now runs first. Fixture: handmade-ole.docx plus an asset-retention test. * fix(rtf): apply per-level list override records (S6) listoverridetable parsing now walks each listoverride group's lfolevel records in level order, overlaying overridden starts (listoverridestartat/levelstartat) and overriding formats (embedded listlevel via listoverrideformat) onto the referenced list definition, so restarted lists no longer continue from the base definition. Fixture: start-override and format-override ls entries in handmade-bin.rtf. * fix(odf): correct list restarts, headers, and continue-list identity (S7) text:start-value now splits the run at every restarting item (not only the first), text:list-header content renders without markers alongside the list, and text:continue-list resolves the continued list by its xml:id (winning over style-scoped continue-numbering). Fixture: handmade-lists.odt. * docs(plan): check off S7 * fix(odf): accept fractional ISO duration components with safe overflow (S8) Durations now accumulate fractional values on any component (P0.5D, PT1.5H, PT1.5M) instead of zeroing them, round to milliseconds, and keep the raw ISO text when the clock format cannot represent the value. Fixture rows cover each fractional position and an overflowing value. * fix(pptx): resolve slide-to-slide links via slide start anchors (S9) Internal slide relationships previously became dead relative links to slides/slideN.xml. Each slide now has a stable start anchor id (slide-N); links to another slide resolve to that anchor, and the anchor node is emitted only on slides some link actually targets. Fixture: handmade-links.pptx (internal jump + external link). * docs(plan): check off S9 * fix(render): preserve cell edge whitespace as numeric references (S10) The table renderer trimmed every cell line, losing the leading/trailing spaces the spreadsheet and CSV parsers deliberately retain. Edge whitespace now renders as numeric character references ( , 	) so the table's own padding cannot swallow it; final-output unit test plus updated quoted-CSV and merged-XLSX snapshots. * fix(package): cache archive parts and deduplicate assets by origin (S12) Repeated references to the same part previously re-decompressed it and re-charged the 512 MiB archive budget, and each reference stored a duplicate asset counted against the retained-bytes cap - so a valid document referencing one image many times could falsely hit either limit. Parts are now cached by normalized name (leading OPC slash stripped) and assets dedupe by origin part. Unit tests cover the budget accounting; handmade-manyrefs.docx covers end-to-end dedup (70 references, one retained asset). * fix(html): cascade with selector specificity and tri-state display (S13) Matching rules now apply in ascending (specificity, source order) within the supported subset (class 10, tag 1), and display is a tri-state so a higher-priority display:block restores content a lower-priority display:none hid. Fixture: specificity chapter in handmade-css-links.epub covering hide-wins, restore-wins, and a tag.class beating a later class rule. * fix(doc): resolve istdBase chains without a depth cutoff (S14) Style inheritance now walks the chain iteratively with a visited set and memoization: valid chains of any depth resolve fully, and cycles resolve from their acyclic prefix instead of silently defaulting past 16 styles. Unit tests cover a 41-deep chain and a cycle. * fix(render): port github-slugger's exact accepted-character table (S15) The slug filter approximated the reference with is_alphanumeric plus a handful of combining-mark ranges, dropping connector punctuation (U+203F etc.) the reference keeps and keeping No-category numbers it drops. The accepted set is now generated (bench/js/gen_slug_table.mjs) directly from the reference package's removal regex - 733 ranges, verified all-or-nothing per codepoint - and the golden corpus gained connector-punctuation and category-edge cases. * refactor(render): own the slug rule; drop the github-slugger harness (S15 rev) Per reviewer direction: github-slugger is an unofficial community package, not ground truth, so the generated accepted-character table, golden generator, TSV corpus, and differential test are removed. The slug rule is now defined by this crate - keep letters, numbers, marks, connector punctuation, and hyphens; spaces become hyphens; empty results become 'section' - covered by direct unit tests. S15's substantive fix stays: connector punctuation (U+203F etc.) is no longer dropped, and the combining-mark list covers viramas and stress signs. * fix(csv): sniff delimiters over complete records, not physical lines (S16) The 20-line sample could cut a quoted multiline field in half and make a semicolon/tab file fall back to comma. Sniffing now samples the first 20 complete records through the streaming reader. Unit test: a quoted field spanning 30 physical lines. * docs(plan): check off S16 * fix(package): separate qualified/unqualified attribute lookups (S17) attr() keeps its documented same-vocabulary leniency, but strict attr_qualified/attr_unqualified lookups now exist, and cross-namespace reads (r:id, xml:id) use the strict form so an unqualified id from another vocabulary can no longer be misattributed. Attribute decoding failures are logged instead of silently flattened away. * fix(odf): detect encryption from the parsed manifest element (S18) is_encrypted now parses META-INF/manifest.xml and looks for the namespaced manifest:encryption-data element instead of substring matching, so a comment mentioning encryption-data no longer classifies a plain document as encrypted. Fixtures: manifest comment (converts) and a real encryption-data manifest (errors Encrypted). * docs(plan): check off S18 * fix(html): represent zero/negative ordered-list numbers literally (S19) Valid ol start/value below 1 was clamped to 1. Lists containing non-positive numbers now carry every number as an explicit literal marker (via the model's marker_label), so -1/0/1 sequences - including reversed lists crossing zero - render faithfully. Fixture rows in handmade-features.epub. * docs(plan): check off S19 * fix(ppt): pair notes pages to slides by stored slide ids (S20) Notes were zipped to slides purely by list order, misattributing notes when the notes list is sparse. Each notes segment now carries its NotesAtom.slideIdRef and each slide segment its SlidePersistAtom.slideId; pairing matches ids, and notes without a resolvable owner keep document order at the end. Fixture: handmade-sparsenotes.ppt with notes only on the second slide. * test(samples): refresh real-world sweep digests for round-3 fixes Drift reviewed per file: preserved cell edge whitespace (S10), corrected DOC/RTF list numbering and number text (S4/S6), ODF list/duration handling (S7/S8), and binary PPT notes now pairing to their actual slides by slideIdRef (S20) - visible as re-homed notes quotes in the SBAR deck. * docs(plan): Phase 17 status - all round-3 findings closed except S11 (=R17b/c) * test(harness): share the fixture walker between integration binaries snapshots.rs and robustness.rs carried identical recursive walkers and fixture-root lookups; both now come from tests/common. * refactor(sheet): narrow the calamine panic barrier to dependency calls The frontend-wide catch_unwind converted bugs in this crate's own grid code into malformed-workbook errors. A narrow contained() adapter now wraps only the calamine operations that decode input (workbook open, sheet listing, worksheet read, merged-region loading); a caught panic still degrades to the same typed error. * refactor(doc,ppt): share OLE-stream and checked byte-reader primitives DOC and PPT each carried private copies of the compound-file stream reader and the checked little-endian integer readers. Both now come from shared::binary; the stream read is hard-capped at MAX_ENTRY_BYTES so a corrupt sector chain cannot expand without bound. Also drops the always-Some Option from TextStream::index_of_cp. * refactor(package): make path-segment percent-decoding infallible decode_segment returned Result but never Err: stray percents pass through literally and non-UTF-8 decodes lossily, which is deliberate for archive-name matching. The signature and a comment now say so. * refactor(package): unified part loading, shared buffers, exact limits - Package::part reports max_total_bytes when the whole-archive budget is the binding cap, not the per-entry limit (with a multi-entry exhaustion test). - New Package::optional_part / optional_xml_part / required_xml_part encode the recovery policy once (absent -> None, unreadable/corrupt -> log + skip, fatal -> propagate); docx, pptx, epub, odf, and read_rels use them, and the duplicated docx/pptx rel_part bodies collapse into relationships::rel_target_bytes. ODF encryption probing now returns Result so ResourceLimit is no longer swallowed (regression tests included). - Part buffers are Rc<[u8]>: cache hits and insertions no longer copy; AssetSink::add takes &[u8] and copies only on actual retention. - AssetSink errors directly from add instead of a deferred limit_hit flag, and the HTML walker propagates Result instead of stashing the first error in a field. Empty ImageSource::External sentinels are replaced by an explicit ImageSource::Unavailable variant. - DOCX ctx_clone (placeholder rels/base_part resets) becomes an explicit Ctx::for_part constructor. - Element descendant lookups share one lazy DescendantNodes iterator; descendants/descendants_any no longer allocate eager Vecs. - pptx parse_para_inlines drops its always-Ok Result. * refactor(render): borrowed normalized runs and named escape options Norm is lifetime-parameterized: text is Cow (owned only where runs actually merge) and links/images/anchors borrow from the source inlines instead of cloning. escape_text's four positional booleans become a documented EscapeOpts struct, and image rendering matches ImageSource::Unavailable explicitly. * refactor(rtf): decompose the parser state machine Table assembly (cell props, per-depth rows, grid construction) moves to a TableState component in table.rs; byte-level text decoding (\uN, surrogates, code-page bytes) into TextDecoder; field, note, bookmark, and list-label state into Destinations. The 222-line control_word dispatcher splits into text/table/list/object handlers with the suppressed-destination list as a named const, and the deferred error field is replaced by Result propagation. * fix(model): materialize covered tails behind short-row gaps finish() only consumed pending covered positions contiguous with each row's end, so a span behind a short row's gap silently vanished while its origin kept the row span, breaking the exactly-once invariant. All pending positions in surviving rows now materialize, filling intervening slots with empty cells. * fix(sheet): intersect merged regions in absolute coordinates Relative saturating arithmetic mapped a merge wholly above or left of the used range onto (0,0), fabricating merges over real cells. The absolute intersection is now computed first and empty intersections are skipped; regression tests build minimal xlsx files for both an in-range and an out-of-range merge. * fix(html): cross-boundary whitespace, one CSS cascade, rowspan=0 - Whitespace collapses across text-node and inline-element boundaries: a lone space inside a span survives between words, formatting splits no longer double spaces, and link labels join the surrounding run. - One cascade for the supported properties: tag presentational defaults, then rules, then inline style, with an !important tier (declarations carrying it no longer fail to parse). - Headings keep their resolved styling minus the redundant bold base. - rowspan="0" spans to the end of its row group per the HTML table model instead of clamping to one. - Ordered-list walks use saturating/checked arithmetic so i64-extreme start/value attributes cannot overflow. * feat(shared): cross-format infrastructure for the round-4 fixes - uri: one RFC 3986 scheme classifier (one-letter schemes valid, digit-first rejected, drive-letter paths carved out as file paths), wired into field targets and EPUB hrefs/images. - numbering: the shared number-pattern IR (literal + level-reference tokens, legal numbering) with percent-pattern parsing and composite-label rendering that yields None when the default marker label is already faithful. - grid: the edge-based table assembly (boundary clustering, horizontal merge folding, vertical chains) extracted from RTF for reuse by the binary DOC TAP path. - officeart: MS-ODRAW blip extraction (PNG/JPEG, deflate-compressed EMF/WMF, FBSE entries) shared by DOC picture data and the PPT Pictures stream; adds the flate2 dependency (already in-tree). - delta: strip_uniform_bold for headings - drop only style-uniform bold, keep italic/strike and explicit inner bold. - assets: rel_image_source resolves external-mode image relationships to ImageSource::External instead of failing the part loader. - list: ordered-run contiguity uses checked arithmetic. * fix(render): escape composite marker labels, keep link-label edges Source-derived marker labels (composite number text, HTML value literals) now render through a dedicated escaping context: control characters collapse to spaces and Markdown syntax is neutralized, so a crafted label cannot alter document structure ("- 0. x" previously parsed as a nested ordered list). Link labels keep source-significant edge spaces; only the emptiness test trims. * fix(odf): heading numbering, faithful list labels, bounded repeats - text:outline-style heading numbering with restart-numbering, start-value, and is-list-header controls; the label (including display-levels chains and prefix/suffix) is prepended to the heading (H1). Headings keep resolved styling minus uniform bold (M10). - List levels carry num-prefix/num-suffix/display-levels; composite labels render as authored instead of a fabricated 'n.' (M2). Start values clamp so counters cannot overflow (H2). - Repeated table rows parse their template once (notes and assets no longer duplicate per repeat) and the duplicated text bytes charge a new max_expansion_text_bytes budget, so the slot budget can no longer admit multi-gigabyte content amplification (H3). - Image hrefs classify through the shared URI helper (L2). * fix(docx): independent heading numbering, tri-state numPr, lvlText - Heading and numbering semantics resolve independently: a numbered heading advances its sequence and keeps its visible label (H1), and heading runs keep inherited italic/strike with only uniform bold stripped (M10). - Direct numPr children merge property-by-property with the style-inherited numPr (missing numId/ilvl inherit; explicit numId 0 suppresses) per ECMA-376 (M1). - w:lvlText/w:isLgl parse into the shared pattern IR, so patterns like 'a)' or '%1.%2' render as authored (M2). w:start clamps to its ST_DecimalNumber range and counters saturate (H2). - w:t edge whitespace is discarded unless xml:space=preserve (M3). - mc:Choice/@Requires prefixes resolve to URIs at parse time in their lexical scope; the document-wide first-declaration-wins prefix map (and the per-element ns_decls storage) is gone (M4). - r:link image relationships become external image sources (M9). * fix(pptx): autonumber suffixes, cascaded titles, deterministic rels - buAutoNum schemes keep their punctuation: ParenR/ParenBoth/Plain render literal labels ('1)', '(1)', '1') while Period stays native (M2). startAt clamps to its 1..32767 range and counters saturate (H2). - Title placeholders resolve runs through the full cascade like body text, keeping italic/strike with uniform bold stripped (M10). - Slide-layout/master/notes relationships pick through first_of_type, so duplicate relationships resolve deterministically (L3). - r:link images become external image sources (M9). * fix(rtf): leveltext patterns, numbered headings, pict extraction - \leveltext/\levelnumbers/\levellegal parse into the shared pattern IR (list-table and override-table levels), so labels like 'a)' render as authored (M2). Legacy \pn seed values clamp (H2). - Numbered headings advance their sequence and keep the visible label; heading styling keeps italic with uniform bold stripped (H1, M10). - \pict destinations extract their payload (hex or \bin) into the asset store, typed by pngblip/jpegblip/emfblip/wmetafile; property subgroups don't contaminate the payload, and the \nonshppict fallback stays suppressed (M14). * fix(doc): Prm0 decoding, TAP tables, pictures, numbered headings - Compressed piece Prms (Prm0) decode per the [MS-DOC] isprm table into one-sprm grpprls for the modeled properties instead of being discarded (M13). - Tables build from sprmTDefTable TAPs through the shared edge-based assembly: TC80 boundaries and horizontal/vertical merge flags (plus sprmTVertMerge and sprmTTableHeader) become real column/row spans and header rows; nested-table content (sprmPItap depth > 1, inner cell/row marks) flattens into the outer cell instead of corrupting outer rows (M13). - Inline pictures (sprmCPicLocation -> PICF + OfficeArt in the Data stream) are retained as assets (M14). - Numbered headings keep their visible label and advance the sequence (H1); heading styling keeps italic with uniform bold stripped (M10). * fix(ppt): retain Pictures-stream media, flag unresolved hyperlinks The deck's Pictures stream (OfficeArt BStore blocks, including FBSE entries) is extracted into document-level assets. ExHyperlinkAtom records log an explicit unresolved-target degradation (M14). * test(corpus): refresh snapshots for the round-4 fixes; document policy Fixture and real-world sweep digests reflect the intended behavior changes: authored numbering patterns ('a)', '2.1.'), heading numbers and preserved heading italic, TAP-backed DOC table columns, recovered spreadsheet header cells, EPUB whitespace normalization, and escaped literal list markers. New retention tests cover RTF/DOC pictures. The crate docs now state the fixed page-chrome policy: headers, footers, page numbers, and date/time placeholders are excluded in every format; speaker notes are always included. * chore: drop REFACTOR_PLAN, ignore __pycache__ * bench: include presentation formats in the sweep * fix(doc): pass symbol characters through, tidy style resolution Symbol/Wingdings characters (U+F000-F0FF) are no longer rewritten to a generic bullet; the actual character passes through with its style. The table-stream fallback now tries the other stream instead of retrying the one that just failed. Stylesheet::get returns a reference instead of deep-cloning per character, note refs drop their unread NoteKind, and the sprm get_u16/get_u32 re-export is gone. * fix(docx): recurse into moveTo and customXml, keep empty-label links w:moveTo (moved-in text, part of the final document) and w:customXml wrappers no longer lose their content in block, inline, and table-row walks. Hyperlinks with an empty label keep their resolved target - the renderer shows the URL as the link text. The mc:AlternateContent branch selection moves to shared::mc for reuse by other OOXML frontends, and piece splitting, gridBefore/gridAfter filler, and field-frame cleanup are deduplicated. * fix(pptx): parse AlternateContent branches and connector text Shapes wrapped in mc:AlternateContent (ink, 2010+ shape features) and cxnSp connectors with text bodies were skipped entirely. Level-style parsing takes the Option directly and placeholder lookups avoid needless allocation. * fix(odf): convert stored index bodies table-of-content, alphabetical-index, bibliography, and illustration-index elements carry their generated entries as regular text in text:index-body; that content is real document text and now converts instead of being dropped wholesale. * fix(ppt): recover speaker notes, keep partial style runs The raw-stream recovery path now descends into notes containers (it has no notes list to walk them from), excluding the notes master by its reserved slide-id range. An unparseable paragraph style run keeps the styling parsed so far instead of discarding the whole shape's runs, and the local record_at duplicate of shared::officeart is gone. * fix(rtf): shape and object text, full codepage scan, literal list markers \shptxt (drawing-object text) and \result (an embedded object's displayable rendering) re-enable text inside their suppressed parent destinations, like \shppict already did. The \ansicpg scan covers the whole header instead of the first 512 bytes. List paragraphs whose \ls has no list-table definition (or with only a bare \listtext) carry the captured \listtext through as their literal marker label instead of degrading to an anonymous bullet. Assorted cleanups: no-op retain removed, PictState::payload takes self, lazy list-def default. * fix(epub): keep non-linear and untyped spine parts linear="no" spine items are auxiliary but still publication content, and media-type substring filtering dropped legal spine documents; both now parse, with unusable parts degrading at parse time as before. * fix(csv): deterministic delimiter tie-break Modal field counts tied in frequency were resolved by hash-map iteration order, so the same bytes could sniff a different delimiter between runs; ties now break toward the wider record shape. * fix(shared): heading styling, captions, anchors, field switches Heading content keeps its real styling: strip_uniform_bold (and the forced non-bold base for HTML headings) discarded explicit bold that is document data. HTML tables emit their caption text, empty headings keep their link-target anchors, links with empty labels keep their URL, and block flattening no longer loses nested table text. In field codes, an argument-taking switch no longer swallows a following switch (\o \l). uri helpers go private. * fix(render): marker widths, nested-table alignment, dead code Continuation indentation counts characters, not bytes, so non-ASCII marker labels no longer over-indent into code blocks. Tables nested in cells keep empty cells in the joined row so values stay in their source columns. Document::add_asset had no callers; note numbering avoids double lookups; inline emission uses write! instead of format! churn. * fix(package): strict hex decode, entity-ref node budget Percent-decoding no longer accepts '+5' as a hex pair, general entity references are charged against the XML node budget like text nodes, and the total-bytes cap subtraction saturates. * test(corpus): refresh snapshots for preserved styling and content Headings keep their styles' explicit bold, RTF text boxes surface their text, the broken-persist PPT recovery includes its speaker notes, and the EPUB non-linear appendix converts with an internal link target. * feat(pdf): convert PDFs via pdf-inspector Format::Pdf routes to pdf-inspector, which emits Markdown directly; PDFs bypass the document model, so to_document errors as unsupported for them. Encrypted PDFs map to Encrypted, structural failures to Malformed, and scanned/image-only PDFs (which need OCR) error as unsupported. The corpus gains a LibreOffice-exported fixture from the same source as the doc/docx/rtf ones, plus a pdf fuzz target. The dependency pins the fix/gid-differences-tounicode branch: 0.1.6 suppresses all markdown when every page has gid-named Differences glyphs, even though their ToUnicode CMap decodes them (LibreOffice exports do this). Repoint to a released version once that fix ships. * test(xml): drop the counting-allocator memory projection The global counting allocator taxed every unit test's allocations to support one projection-based memory assertion; the node cap it guarded is still enforced directly. * style(csv): byte-string delimiter candidates clippy 1.97's byte_char_slices flags the char-by-char array form. * chore: bump pdf-inspector to the FFFD-guarded gid fix