Files
tomsideguide c86ab568a7 refactor: architecture (#1)
* 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 (&#32;, &#9;) 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
2026-07-31 17:31:29 +01:00
..
2026-07-31 17:31:29 +01:00
2026-07-31 17:31:29 +01:00
2026-07-31 17:31:29 +01:00