diff --git a/CHANGELOG.md b/CHANGELOG.md index e5b34bb..5670fb4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -166,6 +166,7 @@ The gap against Word/LibreOffice, most valuable first. Reviewed 2026-08-15. **Content an imported document loses** - Charts are **drawn** from the file (`import/chart.ts`: DrawingML `chartN.xml` and ODF `chart:chart`), but as a picture, not a chart object — a re-export carries the drawing and the numbers behind it are no longer editable. The same holds for an **EMF** metafile (`import/emf.ts`): it is drawn, but as the SVG picture it was rebuilt into, and only from the record set a plot consists of — a hatched brush, a clipping region or a rotated bitmap is skipped. **WMF/SVM** metafiles and OLE objects still keep their box and a placeholder label, and export writes that back out: WMF is a different (16-bit) record format, SVM is StarOffice-proprietary, and an OLE object cannot be rendered without its application +- Two drawings still drop with the "Drawings were removed" warning: a **text box in the header/footer** (the zone is a one-paragraph document, so no block node fits in it — whatever the box carried, a letterhead logo included, goes with it) and a **shape group** (ODF `draw:g`: unwrapping it would anchor every child on its own, and a box cannot be put at a free point — see the limitation below) - A drawing tool: a freeform, a polygon or a connector **imports, draws and saves** (see below), but there is no way to author one here. A Word connector preset (`bentConnector3`) is also still dropped — Word resolves that geometry and writes no path for it **Missing while writing** diff --git a/src/lib/import/CLAUDE.md b/src/lib/import/CLAUDE.md index 5618c47..0af4006 100644 --- a/src/lib/import/CLAUDE.md +++ b/src/lib/import/CLAUDE.md @@ -24,6 +24,7 @@ The **Open .odt** button (`App.svelte`) parses an uploaded file with `importOdt( - **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. **EMF** goes through the same lazy pre-pass into an SVG picture (`import/emf.ts`, below). **SVM/WMF** still have no decoder (SVM is StarOffice-proprietary, WMF a different 16-bit record format) → skipped with the format warning; LibreOffice/WASM conversion was ruled out (no backend; WASM LO is ~300MB). A **CMYK** JPEG loses its ICC profile (`stripCmykIccProfile`): Chromium colour-manages through it, LibreOffice and Word convert naively, and the file's own colours are the ones every word processor shows (measured on the contract fixture's logo: blue (0,80,131) → (0,77,183) against LibreOffice's (0,32,183), black (57,53,54) → (11,11,18) against (0,0,0)). - **EMF (`import/emf.ts`)**: `emfToSvg(bytes)` replays the record stream into SVG — window/viewport plus world transform mapped per point, a DC stack (`SAVEDC`/`RESTOREDC`), pens/brushes/fonts (incl. the stock objects), the 16- and 32-bit poly records, path brackets, `RECTANGLE`/`ELLIPSE`, `EXTTEXTOUT` (its per-character advances become `textLength`, so labels keep their width) and `STRETCHDIBITS` (the DIB re-wrapped as a .bmp data-URI). **Text is stored as raw code units**, so a symbol font leaves lone surrogates behind — they must be stripped or `encodeURIComponent` throws and the whole picture is lost. Unknown records are skipped; a file that yields nothing returns null and keeps the placeholder. - **Charts (`import/chart.ts`, DOCX)**: `word/charts/chartN.xml` is parsed and **drawn as an SVG picture** of the frame's own size — series values from the `numCache` the producer left, colours from the named theme accent (`themeAccents`), title/axis titles/gridlines, and the value axis' own `c:scaling` bounds where the file fixes them. Bar (incl. stacked and `barDir="bar"`), line, area, scatter and pie; anything else keeps the placeholder. Read-only — the editor has no chart object, so a re-export carries the picture, not the chart. **ODF** charts go through the same drawing (`odfChartDataUrl`, `convertChartFrame`): `` in the `draw:object`'s sub-document, series values read out of its own `local-table` by cell range, colours from the series' `draw:fill-color`, bounds from the y-axis style's `chart:maximum`/`-minimum`. +- **Word's horizontal line** (`hrBorderAttr`, DOCX): a `w:pict` > `v:rect` flagged `o:hr` is not a shape but the paragraph's own **bottom rule** — the editor has no rule node. Height and `fillcolor` become the rule (`1.5pt`/`#A0A0A0` where the file names none), its width and `o:hralign` are dropped (a paragraph rule spans the text width) and a real `w:pBdr` bottom keeps precedence: only one line can be drawn. - **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 (a fixture measured against LibreOffice 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`. - **Bookmarks & cross-references**: `text:bookmark-start`/`-end` (ODF) and `w:bookmarkStart`/`End` (DOCX) become a `bookmark` **mark** on the text they cover; the open ranges live in `Ctx` because Word writes them beside paragraphs as well as beside runs, and a range may close in a later paragraph. A **point** bookmark has no range to mark and is dropped, `_GoBack` with it. `text:bookmark-ref` and `REF`/`PAGEREF` fields (the `fldChar` machine and `w:fldSimple` alike, including a field Word left unresolved) become `crossRef` nodes carrying the producer's cached text. A `w:hyperlink w:anchor` with no `r:id` is an internal link → `#name`. See `docs/architecture/formatting.md`. diff --git a/src/lib/import/docx.ts b/src/lib/import/docx.ts index 86373d5..ac01fc2 100644 --- a/src/lib/import/docx.ts +++ b/src/lib/import/docx.ts @@ -1,5 +1,5 @@ import { unzipSync, strFromU8 } from 'fflate'; -import { DocxStyles, parseRunProps, mergeRunProps, readNumPr, readTabStops, toggle as onOff, wVal, W, R, WP, A, B, WPS, MC, VML, PKG_REL, type RunProps, type ParaSpacing } from './docxStyles'; +import { DocxStyles, parseRunProps, mergeRunProps, readNumPr, readTabStops, toggle as onOff, wVal, W, R, WP, A, B, WPS, MC, VML, O, PKG_REL, type RunProps, type ParaSpacing } from './docxStyles'; import { lengthToPt, WATERMARK_NAME } from './styleResolver'; import { HEADING_STYLE_OVERRIDES, MAX_HEADING_LEVEL, normalizeColor } from '../export/odt'; import { builtinStyleSheet, DEFAULT_STYLE, type ParaProps, type Style, type StyleSheet, type TextProps } from '../styles/styleSheet'; @@ -948,6 +948,10 @@ function convertParagraph(el: Element, ctx: Ctx, kind: BlockKind, boldByDefault: const attrs = blockAttrs(ppr, kind, level, directJc ? jcVal : null, kind === 'cell' ? ctx.styles.paragraphSpacing(styleId, ctx.cellSpacing) : {}); applyContextualSpacing(el, ppr, ctx, styleId, attrs); + // The editor has no rule node, so Word's horizontal line becomes its paragraph's own + // bottom rule — a real w:pBdr keeps precedence, only one line can be drawn. + const hr = hrBorderAttr(el); + if (hr) attrs.borderBottom ??= hr; // The baked-in cell chain must not accrete no-op direct formatting: in a cell, // unset spacing renders 0/0 (no word processor passes the default style's spacing // into a cell), and single line height is only kept over a non-single default. @@ -1153,6 +1157,21 @@ function paraBorderAttr(b: Element | null): string | null { return `${widthPt}pt solid ${hexColor(b.getAttributeNS(W, 'color')) ?? '#000000'}`; } +// Word's horizontal line (Insert ▸ Horizontal Line, or the `---` autoformat) is a VML +// rect flagged o:hr, alone in its paragraph — height and fill are the line it draws. +// Its own width and alignment are dropped: a paragraph rule spans the text width. +function hrBorderAttr(p: Element): string | null { + const rect = Array.from(p.getElementsByTagNameNS(VML, 'rect')).find(isHrRect); + if (!rect) return null; + const h = /height:\s*([\d.]+\s*[a-z]*)/.exec(rect.getAttribute('style') ?? '')?.[1]; + const pt = lengthToPt(h?.replace(/\s+/g, '')) ?? 1.5; + // Word's own default for a line with no fill colour. + const color = normalizeColor(rect.getAttribute('fillcolor') ?? '') ?? '#A0A0A0'; + return `${Math.round(pt * 100) / 100}pt solid ${color}`; +} + +const isHrRect = (el: Element): boolean => (el.getAttributeNS(O, 'hr') ?? 'f') !== 'f'; + function snapPt(v: number): number { const r = Math.round(v * 100) / 100; const i = Math.round(r); @@ -2011,6 +2030,8 @@ function convertPict(pict: Element, ctx: Ctx): Node | null { // The watermark rides the page decoration (storage/pageDecor.ts), not the header's // content, so it must not also arrive here as a shape. if (shape.getAttribute('id') === WATERMARK_NAME) return null; + // A horizontal line rides its paragraph as a bottom rule (convertParagraph), not a shape. + if (isHrRect(shape)) return null; const style = shape.getAttribute('style') ?? ''; const dimPx = (key: string): number | null => { diff --git a/src/lib/import/docxStyles.ts b/src/lib/import/docxStyles.ts index b287738..1e42a78 100644 --- a/src/lib/import/docxStyles.ts +++ b/src/lib/import/docxStyles.ts @@ -16,6 +16,8 @@ export const PKG_REL = 'http://schemas.openxmlformats.org/package/2006/relations export const WPS = 'http://schemas.microsoft.com/office/word/2010/wordprocessingShape'; export const MC = 'http://schemas.openxmlformats.org/markup-compatibility/2006'; export const VML = 'urn:schemas-microsoft-com:vml'; +// VML's Office extensions (o:hr, o:hralign, …). +export const O = 'urn:schemas-microsoft-com:office:office'; // The bibliography sources Word keeps in a custom-XML part. export const B = 'http://schemas.openxmlformats.org/officeDocument/2006/bibliography'; diff --git a/tests/unit/docx-horizontal-rule.test.ts b/tests/unit/docx-horizontal-rule.test.ts new file mode 100644 index 0000000..ecfe0df --- /dev/null +++ b/tests/unit/docx-horizontal-rule.test.ts @@ -0,0 +1,32 @@ +// Word's horizontal line is a VML rect (o:hr) in a paragraph of its own; the editor +// draws it as that paragraph's bottom rule instead of dropping it as a shape. +import { describe, it, expect } from 'vitest'; +import { zipSync, strToU8 } from 'fflate'; +import { importDocx } from '../../src/lib/import/docx'; + +const doc = (body: string) => zipSync({ + 'word/document.xml': strToU8(` + + ${body} +`), +}); + +const HR = ''; + +describe('DOCX horizontal line', () => { + it('becomes the paragraph bottom rule, with no warning', () => { + const res = importDocx(doc(`Text${HR}`)); + const rule = res.content.content[1]; + expect(rule.attrs.borderBottom).toBe('1.5pt solid #A0A0A0'); + expect([...res.warnings]).not.toContain('Drawings were removed'); + }); + + it('leaves a real w:pBdr bottom border alone', () => { + const bdr = ''; + const res = importDocx(doc(HR.replace('', `${bdr}`))); + expect(res.content.content[0].attrs.borderBottom).toBe('3pt solid #FF0000'); + }); +});