feat: Word's horizontal line imports as the paragraph's rule
A v:rect flagged o:hr is the line itself, not a shape: its height and fillcolor become the paragraph's borderBottom (1.5pt/#A0A0A0 where the file names none), a real w:pBdr keeping precedence — only one line can be drawn. It was dropped with the "Drawings were removed" warning until now, which the changelog's gap list did not admit to either.
This commit is contained in:
1 parent
6c731b9233
commit
eb3a9f95c1
5 files changed
+58
-1
No files matched your search
@@ -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**
|
||||
|
||||
@@ -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):** `<img>` 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 `<img>`. A frame's multiple `<draw:image>` alternatives (ODF) and a Word `a:blip`'s `svgBlip` alternative are probed for the first *displayable* one, and `<draw:a>`-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`): `<chart:chart>` 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 `<draw:frame>` whose `<draw:object>` resolves to a sub-document with a `math:math` root (or holds one inline) → a `formula` node. Our own `<annotation encoding="application/x-tex">` 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`.
|
||||
|
||||
+22
-1
@@ -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 => {
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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(`<?xml version="1.0"?>
|
||||
<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"
|
||||
xmlns:v="urn:schemas-microsoft-com:vml"
|
||||
xmlns:o="urn:schemas-microsoft-com:office:office">
|
||||
<w:body>${body}</w:body>
|
||||
</w:document>`),
|
||||
});
|
||||
|
||||
const HR = '<w:p><w:r><w:pict><v:rect id="_x0000_i1025" style="width:0;height:1.5pt"'
|
||||
+ ' o:hralign="center" o:hr="t" fillcolor="#a0a0a0" stroked="f"/></w:pict></w:r></w:p>';
|
||||
|
||||
describe('DOCX horizontal line', () => {
|
||||
it('becomes the paragraph bottom rule, with no warning', () => {
|
||||
const res = importDocx(doc(`<w:p><w:r><w:t>Text</w:t></w:r></w:p>${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 = '<w:pPr><w:pBdr><w:bottom w:val="single" w:sz="24" w:color="FF0000"/></w:pBdr></w:pPr>';
|
||||
const res = importDocx(doc(HR.replace('<w:r>', `${bdr}<w:r>`)));
|
||||
expect(res.content.content[0].attrs.borderBottom).toBe('3pt solid #FF0000');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user