diff --git a/src/lib/import/CLAUDE.md b/src/lib/import/CLAUDE.md index 830cb5b..0d13c51 100644 --- a/src/lib/import/CLAUDE.md +++ b/src/lib/import/CLAUDE.md @@ -11,6 +11,7 @@ The **Open .odt** button (`App.svelte`) parses an uploaded file with `importOdt( - **Default suppression** keeps round trips (editor → LibreOffice → editor) from accreting explicit attrs: values matching the editor's defaults (12pt body, `HEADING_STYLE_OVERRIDES` sizes/margins, **0 paragraph spacing**, list margin 0, Times New Roman/Liberation Serif, `#000000` text) are imported as `null`/no mark, with small tolerances for producer unit rounding (pt↔cm↔twips). This is why the editor's defaults must equal Word's/LibreOffice's (below) — an editor-only default is indistinguishable from a failed style resolution and silently lands in every imported document. - **Mapping:** `text:h` → heading (level clamped to `MAX_HEADING_LEVEL`), per-depth `text:list-level-style-*` defs decide bullet vs ordered and `listStyleType` (reverse lookup `orderedTypeFromFormat`), tables handle covered cells / `number-columns-repeated` / header rows / colspans / cell shading (`StyleResolver.cellBackgroundColor` → `backgroundColor`) / cell borders (`StyleResolver.cellBorders` + `borderAttrFromOdf` → the per-side border attrs) / table margins (`tableMargins` reads the table style's `fo:margin-*`, `style:width`/`style:rel-width` and `table:align` against `Ctx.contentWidthCm` — so a table narrower than the text width stays narrow instead of being stretched; DOCX does the same from `w:tblInd` + the `w:tblGrid`/`w:tblW` width), `text:s`/`text:tab`/`text:line-break` become spaces/`\t`/`hardBreak`, `draw:frame`/`draw:image` → an `image` node (`convertFrame`: bytes read from the zip's `Pictures/` entry into a data-URI, `svg:width`/`-height` cm → px, `svg:title` → `alt`, `draw:transform` rotate → `rotation`; a non-`as-char` anchor or a graphic style's `style:wrap` → floating `wrap` mode via `StyleResolver.graphicProps` — an **explicit `as-char` anchor is always inline**: LibreOffice's named Graphics style carries a `style:wrap` that would otherwise float every inherited frame), text fields keep their stored value. **Text boxes / shapes** (`convertDrawElement`): `draw:frame`+`draw:text-box` → a `textBox` node (`convertTextBoxFrame`: height = `fo:min-height` else `svg:height`; fill/stroke read straight from `graphicProps`' `draw:fill(-color)`/`draw:stroke`/`svg:stroke-*`, values equal to the editor defaults suppressed, none → explicit `null`); `draw:rect`/`draw:ellipse`/`draw:custom-shape` with a rect/round-rectangle/ellipse preset → a `textBox` with the matching `shapeKind` (`convertShape`; other presets → "Unsupported shapes were removed"). A box is a block node but its frame sits inside a `text:p`, so converted boxes ride `Ctx.pendingBlocks` and `convertBlocks` flushes them after the anchor paragraph (dropping our own export's empty anchor); in cells/other boxes their blocks are unwrapped in place with a warning. The DOCX importer mirrors all of this (`convertWpsShape` for DrawingML `wps:wsp` — probed before the image `a:blip` path, `convertPict` for legacy VML; Word's `mc:AlternateContent` uses only the `mc:Choice` branch so the VML fallback can't double-import). - **Image formats (`import/imageFormats.ts`, shared by both importers):** `` only renders a fixed set of formats, so `imageDataUrl(bytes, path)` resolves the mime by extension **then** magic-number (mislabelled/extensionless entries still work) and returns `null` for anything unrenderable — the importer then skips it with a specific "format the browser can't display" warning instead of a broken ``. A frame's multiple `` alternatives (ODF) and a Word `a:blip`'s `svgBlip` alternative are probed for the first *displayable* one, and ``-wrapped frames are unwrapped (the hyperlink dropped). **Non-web formats we can decode client-side** (currently **TIFF**) are converted to PNG up front by `convertUnsupportedImages(bytes)` — an async pass `applyImport` runs before the synchronous import, passing a `path → PNG data-URI` map (`ConvertedImages`) into `importOdt`/`importDocx` (default empty, so tests stay sync). The decoder (`utif2`) is a **dynamic `import()`** → its own lazy chunk, loaded only when a convertible image is actually present, so ordinary docs pay nothing. **SVM/WMF/EMF** have no JS decoder (SVM is StarOffice-proprietary) → skipped with the format warning; LibreOffice/WASM conversion was ruled out (no backend; WASM LO is ~300MB). +- **Content controls** (`inlineChildren`, DOCX): a `w:sdt` inside a paragraph — Word wraps every citation and bibliography entry in one — is walked through to its `w:sdtContent`, or the runs inside it never reach the paragraph (the thesis fixture lost every `[7]`-style reference and broke its lines one page short). - **Formulas** (`convertFormulaFrame`): a `` whose `` resolves to a sub-document with a `math:math` root (or holds one inline) → a `formula` node. Our own `` is preferred; LibreOffice strips it, so the MathML is parsed back to LaTeX instead. **`display` comes from the paragraph** (`aloneInParagraph`), not the object — LibreOffice writes `display="block"` on every formula it re-saves. DOCX: `m:oMath`/`m:oMathPara` sit beside the `w:r` runs in their own namespace, so `convertInline` probes them ahead of its `namespaceURI !== W` guard (that guard silently dropped every Word formula). See `docs/architecture/formulas.md`. - **Graceful degradation** (collected as `warnings`, surfaced once): footnotes/comments dropped, hyperlinks flattened to text, nested tables flattened to paragraphs, non-`Pictures` drawings dropped, unreadable pictures skipped (each with a warning). Page size is normalized to A4. Floating/wrapped images are imported as left/right/top-bottom wrap (free x/y positions collapse to the nearest side; see `image.ts`). diff --git a/src/lib/import/docx.ts b/src/lib/import/docx.ts index f5ae471..1536c50 100644 --- a/src/lib/import/docx.ts +++ b/src/lib/import/docx.ts @@ -894,6 +894,17 @@ function snapPt(v: number): number { } // ---- inline conversion (runs, marks, fields, images) ----------------------- +// Word wraps a citation, a bibliography entry or any content control in a w:sdt whose +// content is ordinary inline content — walk through the wrapper, or the runs inside it +// (a citation's whole visible text) never reach the paragraph. +function inlineChildren(el: Element): Element[] { + return Array.from(el.children).flatMap((c) => { + if (c.namespaceURI !== W || c.localName !== 'sdt') return [c]; + const content = fc(c, 'sdtContent'); + return content ? inlineChildren(content) : []; + }); +} + function convertInline(p: Element, ctx: Ctx, baseRun: RunProps, defaults: BlockDefaults, hfFields: boolean): Node[] { const out: Node[] = []; let fieldMode: 'none' | 'instr' | 'result' = 'none'; @@ -1004,7 +1015,7 @@ function convertInline(p: Element, ctx: Ctx, baseRun: RunProps, defaults: BlockD } }; - for (const el of Array.from(p.children)) { + for (const el of inlineChildren(p)) { // Math is its own namespace and sits beside the w:r runs, so it has to be picked // up before the w:-only guard below drops it. if (el.namespaceURI === OMML_NS && (el.localName === 'oMath' || el.localName === 'oMathPara')) { diff --git a/tests/render-parity/README.md b/tests/render-parity/README.md index d7eea6b..e361e20 100644 --- a/tests/render-parity/README.md +++ b/tests/render-parity/README.md @@ -87,8 +87,8 @@ editor (`global.css`), so give LibreOffice the same answer: What the three real-world fixtures — the contract (an IHK contract form), the summary (a student summary: bullets, wrapped pictures, one table) and the thesis (a 48-page thesis with charts, -metafile figures and a German TOC) — still report. The thesis is at 48 pages against -LibreOffice's 48, with 62 line-level differences, most of them the justification rule above: +metafile figures and a German TOC) — still report. The thesis matches LibreOffice's 49 pages, +with 51 line-level differences — most of them the two engine rules below, not layout: - **A hoisted text box loses the vertical offset it was anchored by.** A frame's own offsets are drawn now (`docs/architecture/frames.md`), but a text box anchored inside a @@ -129,7 +129,12 @@ LibreOffice's 48, with 62 line-level differences, most of them the justification (1.909cm) instead. So reading the attribute back naively moves the markers of our own exports. Whatever rule that is has to be understood before the value is trusted. - Lines whose natural width lands within ~0.2mm of the right margin may take one word - more or fewer than LibreOffice. Sub-0.1mm engine rounding, not a layout rule. + more or fewer than LibreOffice. Sub-0.1mm engine rounding, not a layout rule — but it + **accumulates**: measured on the thesis' page 2, matching word widths (11.86mm for + `haben,` on both sides) had drifted 0.45mm apart by the 150th mm of the line, which is + what decides whether the next word fits. LibreOffice quantizes each glyph advance where + Chromium keeps it fractional, and nothing in CSS exposes that. Most of the thesis' + remaining `lineBreak` reports are this. ## Findings that did not survive measurement