feat: a drawing group opens as the frames it holds
The editor has no group node, so wpg:wgp is mapped out of its child coordinate space: the first child keeps the group's own placement — which is what reserves an inline group's box in the line — and the rest run through the text over it at their place in the group. A label over a picture needs the box in front of the text, so inFront now rides a text box through both formats as well.
This commit is contained in:
1 parent
1789505431
commit
1b7d2e0de8
5 files changed
+109
-28
No files matched your search
@@ -964,6 +964,7 @@ type TextBoxDocx = {
|
||||
distCm: number | null;
|
||||
alignH: string | null;
|
||||
fromPage: boolean;
|
||||
inFront: boolean;
|
||||
shapeKind: ShapeKind;
|
||||
shapePath: string | null;
|
||||
flipV: boolean;
|
||||
@@ -991,6 +992,7 @@ function textBoxDocxDescriptor(node: TiptapNode): TextBoxDocx {
|
||||
distCm: typeof a.wrapDist === 'number' ? a.wrapDist : null,
|
||||
alignH: a.wrapAlign === 'center' || a.wrapAlign === 'right' || a.wrapAlign === 'left' ? a.wrapAlign : null,
|
||||
fromPage: a.wrapFromPage === true,
|
||||
inFront: a.inFront === true,
|
||||
shapeKind: isShapeKind(a.shapeKind) ? a.shapeKind : 'textbox',
|
||||
shapePath: typeof a.shapePath === 'string' && a.shapePath ? a.shapePath : null,
|
||||
flipV: a.flipV === true,
|
||||
@@ -1392,7 +1394,7 @@ function textBoxDrawingXml(box: TextBoxDocx, index: number, parts: TxbxParts): s
|
||||
: `<wp:align>${align}</wp:align>`;
|
||||
return (
|
||||
`<w:drawing><wp:anchor ${WP_NS} distT="0" distB="0" distL="${emu(box.distCm ?? 0)}" distR="${emu(box.distCm ?? 0)}"` +
|
||||
` simplePos="0" relativeHeight="${251658240 + index}" behindDoc="${box.wrap === 'through' ? 1 : 0}" locked="0" layoutInCell="1" allowOverlap="${box.wrap === 'through' ? 1 : 0}">` +
|
||||
` simplePos="0" relativeHeight="${251658240 + index}" behindDoc="${box.wrap === 'through' && !box.inFront ? 1 : 0}" locked="0" layoutInCell="1" allowOverlap="${box.wrap === 'through' ? 1 : 0}">` +
|
||||
`<wp:simplePos x="0" y="0"/>` +
|
||||
`<wp:positionH relativeFrom="margin">${posH}</wp:positionH>` +
|
||||
(box.fromPage
|
||||
|
||||
@@ -986,6 +986,7 @@ type TextBoxExport = {
|
||||
wrapDistCm: number | null;
|
||||
wrapAlign: string | null;
|
||||
wrapFromPage: boolean;
|
||||
inFront: boolean;
|
||||
paddingCm: number;
|
||||
shapeKind: ShapeKind;
|
||||
shapePath: string | null;
|
||||
@@ -1014,6 +1015,7 @@ function textBoxDescriptor(node: TiptapNode): TextBoxExport {
|
||||
wrapDistCm: typeof a.wrapDist === 'number' ? round3(a.wrapDist) : null,
|
||||
wrapAlign: a.wrapAlign === 'center' || a.wrapAlign === 'right' ? a.wrapAlign : null,
|
||||
wrapFromPage: a.wrapFromPage === true,
|
||||
inFront: a.inFront === true,
|
||||
paddingCm: typeof a.paddingCm === 'number' ? round3(a.paddingCm) : TEXTBOX_PADDING_CM,
|
||||
shapeKind: isShapeKind(a.shapeKind) ? a.shapeKind : 'textbox',
|
||||
shapePath: typeof a.shapePath === 'string' && a.shapePath ? a.shapePath : null,
|
||||
@@ -4480,7 +4482,8 @@ function textBoxGraphicStyle(box: TextBoxExport, index: number): string {
|
||||
// where a style-less as-char frame (an image) stands on it (probed).
|
||||
const wrap = box.wrap === 'inline'
|
||||
? ' style:vertical-pos="top" style:vertical-rel="baseline"'
|
||||
: ` ${imageWrapProps(box.wrap, box.wrapOffsetCm, box.wrapAlign, box.wrapDistCm, 'left')} style:number-wrapped-paragraphs="no-limit"` +
|
||||
: ` ${imageWrapProps(box.wrap, box.wrapOffsetCm, box.wrapAlign, box.wrapDistCm, 'left')} style:number-wrapped-paragraphs="no-limit"`
|
||||
+ (box.wrap === 'through' && box.inFront ? ' style:run-through="foreground"' : '') +
|
||||
` style:horizontal-rel="paragraph-content"` +
|
||||
` style:vertical-pos="${box.wrapOffsetYCm != null ? 'from-top' : 'top'}"` +
|
||||
` style:vertical-rel="${box.wrapFromPage ? 'page' : 'paragraph'}"`;
|
||||
|
||||
@@ -26,7 +26,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.
|
||||
- **A heading is bold by default only where the bold built-in still renders it.** `blockDefaults` suppresses a run mark the block's style supplies, and the editor's `Heading` parent supplies bold — but a file that declares its own heading style re-parents it to `Standard` (`collectStyleSheet`), so nothing supplies bold there and an explicit `w:b` / `fo:font-weight` has to stay a mark or the heading renders regular. Gated on the style's own `w:basedOn` / `style:parent-style-name` (`styleBasedOn`, `styleParent`), which is exactly what decides whether the built-in's parent survives.
|
||||
- **A run's underline is suppressed only where it draws the *same* line.** `lineSig` compares the run's shape and colour against the style's, so a red or wavy underline under a plainly underlining style survives — only a bare one repeating its style is dropped, which is what neither format can tell from a failed resolution. Both importers, `marksFor`; a strike compares its `double` the same way.
|
||||
- **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`) — unless the list style is a **named** one (`StyleResolver.isNamedListStyle`): then the outermost list gets `listStyleName` (display name decoded), no per-level attrs are derived, and `collectStyleSheet`/`listStyleFromOdf` put the definition into `sheet.list`, 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`), and `draw:polygon`/`polyline`/`path`/`connector` — or a preset no gallery covers but whose `draw:enhanced-path` is a plain outline — → a box carrying that outline as `shapePath` (`convertFreeform`, `docs/architecture/frames.md`; a geometry built from modifier formulas still reads as "Unsupported shapes were removed"). A box is an inline node, so it stays exactly where the frame sits — in the paragraph's own content, beside the runs around it, cells and list items included; only a box **inside another box** is unwrapped in place, with a warning. The DOCX importer mirrors all of this — its drawing root is the `w:drawing`'s **direct** `wp:inline`/`wp:anchor` child, since a box holding a picture nests a second drawing and a subtree search reads *its* root instead, dropping the box and the caption in it (`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).
|
||||
- **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`) — unless the list style is a **named** one (`StyleResolver.isNamedListStyle`): then the outermost list gets `listStyleName` (display name decoded), no per-level attrs are derived, and `collectStyleSheet`/`listStyleFromOdf` put the definition into `sheet.list`, 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`), and `draw:polygon`/`polyline`/`path`/`connector` — or a preset no gallery covers but whose `draw:enhanced-path` is a plain outline — → a box carrying that outline as `shapePath` (`convertFreeform`, `docs/architecture/frames.md`; a geometry built from modifier formulas still reads as "Unsupported shapes were removed"). A box is an inline node, so it stays exactly where the frame sits — in the paragraph's own content, beside the runs around it, cells and list items included; only a box **inside another box** is unwrapped in place, with a warning. The DOCX importer mirrors all of this — its drawing root is the `w:drawing`'s **direct** `wp:inline`/`wp:anchor` child, since a box holding a picture nests a second drawing and a subtree search reads *its* root instead, dropping the box and the caption in it (`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). A **group** (`wpg:wgp`, read before either) has no node of its own: `convertGroup` maps each child out of the group's child coordinate space (`a:chOff`/`a:chExt`) and gives the first the group's own placement — which is what reserves an inline group's box in the line — while the rest run through the text over it, offset by their place in the group. The frames over an inline group take their static position from the paragraph, so a group that shares its line carries them there; a group inside a group is dropped.
|
||||
- **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). Both passes read the **same inflated archive** — `unzipArchive(bytes)` keeps it per byte array, so a picture-heavy file is inflated once however many passes read it (a quarter second on a 14 MB file) — and a picture is encoded with the browser's `Uint8Array.toBase64` where there is one, the chunked `String.fromCharCode` loop standing in where there is not (300 ms against 15 ms for 600 pictures). 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`.
|
||||
|
||||
+100
-25
@@ -1,5 +1,5 @@
|
||||
import { strFromU8 } from 'fflate';
|
||||
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 { DocxStyles, parseRunProps, mergeRunProps, readNumPr, readTabStops, toggle as onOff, wVal, W, R, WP, A, B, WPS, WPG, MC, VML, O, PKG_REL, type RunProps, type ParaSpacing } from './docxStyles';
|
||||
import { lengthToPt, WATERMARK_NAME } from './styleResolver';
|
||||
import { normalizeColor } from '../export/odt';
|
||||
import { HEADING_STYLE_OVERRIDES, MAX_HEADING_LEVEL } from '../styles/headings';
|
||||
@@ -1563,14 +1563,15 @@ function convertInline(p: Element, ctx: Ctx, baseRun: RunProps, defaults: BlockD
|
||||
// Route a drawing/pict result: both a picture and a text box are inline and stay
|
||||
// where the drawing sits. The one-paragraph header/footer zone takes as-char images
|
||||
// only — boxes and floating page-sized drawings (backgrounds, watermarks) go.
|
||||
const pushDrawn = (n: Node | null, floating: boolean) => {
|
||||
if (!n) return;
|
||||
if (hfFields) {
|
||||
if (n.type === 'image' && !floating) { out.push({ ...n, attrs: { ...n.attrs, wrap: 'inline' } }); return; }
|
||||
ctx.warnings.add('Drawings were removed');
|
||||
return;
|
||||
const pushDrawn = (n: Node | Node[] | null, floating: boolean) => {
|
||||
for (const one of Array.isArray(n) ? n : n ? [n] : []) {
|
||||
if (hfFields) {
|
||||
if (one.type === 'image' && !floating) out.push({ ...one, attrs: { ...one.attrs, wrap: 'inline' } });
|
||||
else ctx.warnings.add('Drawings were removed');
|
||||
continue;
|
||||
}
|
||||
out.push(one);
|
||||
}
|
||||
out.push(n);
|
||||
};
|
||||
|
||||
// Set when this run's w:footnoteReference declares a custom mark: the run's own
|
||||
@@ -2030,7 +2031,23 @@ function drawingIsFloating(el: Element): boolean {
|
||||
return /(^|;)\s*position\s*:\s*absolute/i.test(shape?.getAttribute('style') ?? '');
|
||||
}
|
||||
|
||||
function convertDrawing(drawing: Element, ctx: Ctx): Node | null {
|
||||
// The picture behind an a:blip, as a data URL. Candidate media: the primary blip plus
|
||||
// any SVG alternative (Word 2016+ stores an svgBlip beside a raster fallback) — prefer
|
||||
// whichever the browser can display.
|
||||
function blipSrc(blip: Element, ctx: Ctx): string | null {
|
||||
const candidates: string[] = [];
|
||||
const pushEmbed = (id: string | null) => {
|
||||
const rel = id ? ctx.rels.get(id) : undefined;
|
||||
if (rel && !rel.external) candidates.push(`word/${rel.target.replace(/^\/+/, '')}`);
|
||||
};
|
||||
pushEmbed(blip.getAttributeNS(R, 'embed'));
|
||||
for (const sb of Array.from(blip.getElementsByTagName('*')))
|
||||
if (sb.localName === 'svgBlip') pushEmbed(sb.getAttributeNS(R, 'embed'));
|
||||
if (!candidates.length) return null;
|
||||
return loadImageDataUrl(candidates.find((p) => loadImageDataUrl(p, ctx)) ?? candidates[0], ctx);
|
||||
}
|
||||
|
||||
function convertDrawing(drawing: Element, ctx: Ctx): Node | Node[] | null {
|
||||
// The drawing's own root, as a direct child: a text box holding a picture nests a
|
||||
// second drawing, and a subtree search finds *its* wp:inline first — the box, and
|
||||
// the caption in it, are then read as the bare picture.
|
||||
@@ -2039,6 +2056,9 @@ function convertDrawing(drawing: Element, ctx: Ctx): Node | null {
|
||||
);
|
||||
if (!root) return null;
|
||||
const anchor = root.localName === 'anchor' ? root : undefined;
|
||||
// A group holds shapes and pictures of its own, so it is read before either.
|
||||
const wgp = root.getElementsByTagNameNS(WPG, 'wgp')[0];
|
||||
if (wgp) return convertGroup(wgp, root, anchor, ctx);
|
||||
// A wordprocessingShape (text box / preset shape) has no blip — convert it first.
|
||||
const wsp = root.getElementsByTagNameNS(WPS, 'wsp')[0];
|
||||
if (wsp) return convertWpsShape(wsp, root, !!anchor, ctx);
|
||||
@@ -2060,19 +2080,7 @@ function convertDrawing(drawing: Element, ctx: Ctx): Node | null {
|
||||
ctx.warnings.add('Drawings were removed');
|
||||
return null;
|
||||
}
|
||||
// Candidate media: the primary blip plus any SVG alternative (Word 2016+ stores an
|
||||
// svgBlip beside a raster fallback) — prefer whichever the browser can display.
|
||||
const candidates: string[] = [];
|
||||
const pushEmbed = (id: string | null) => {
|
||||
const rel = id ? ctx.rels.get(id) : undefined;
|
||||
if (rel && !rel.external) candidates.push(`word/${rel.target.replace(/^\/+/, '')}`);
|
||||
};
|
||||
pushEmbed(blip.getAttributeNS(R, 'embed'));
|
||||
for (const sb of Array.from(blip.getElementsByTagName('*')))
|
||||
if (sb.localName === 'svgBlip') pushEmbed(sb.getAttributeNS(R, 'embed'));
|
||||
if (!candidates.length) { ctx.warnings.add('Some images could not be read and were skipped'); return null; }
|
||||
const path = candidates.find(p => loadImageDataUrl(p, ctx)) ?? candidates[0];
|
||||
const src = loadImageDataUrl(path, ctx);
|
||||
const src = blipSrc(blip, ctx);
|
||||
if (!src) {
|
||||
if (boxPx.w > 0 && boxPx.h > 0) {
|
||||
ctx.warnings.add('Images in a format the browser can’t display (e.g. WMF, EMF) were replaced by a placeholder');
|
||||
@@ -2110,6 +2118,67 @@ function convertDrawing(drawing: Element, ctx: Ctx): Node | null {
|
||||
return { type: 'image', attrs };
|
||||
}
|
||||
|
||||
// A drawing group (wpg:wgp): the editor has no group node, so each child becomes a frame
|
||||
// of its own, mapped out of the group's child coordinate space (a:chOff/a:chExt). The
|
||||
// first keeps the group's own placement — that is what reserves an inline group's box —
|
||||
// and the rest ride over it as run-through frames at their place in it.
|
||||
function convertGroup(wgp: Element, root: Element, anchor: Element | undefined, ctx: Ctx): Node[] {
|
||||
const xfrm = nsChild(nsChild(wgp, WPG, 'grpSpPr'), A, 'xfrm');
|
||||
const chOff = nsChild(xfrm, A, 'chOff');
|
||||
const scale = (axis: 'cx' | 'cy') => {
|
||||
const child = intAttr(nsChild(xfrm, A, 'chExt'), '', axis) ?? 0;
|
||||
return child > 0 ? (intAttr(nsChild(xfrm, A, 'ext'), '', axis) ?? child) / child : 1;
|
||||
};
|
||||
const sx = scale('cx');
|
||||
const sy = scale('cy');
|
||||
const boxOf = (el: Element) => {
|
||||
const own = el.getElementsByTagNameNS(A, 'xfrm')[0];
|
||||
const off = nsChild(own, A, 'off');
|
||||
const ext = nsChild(own, A, 'ext');
|
||||
return {
|
||||
x: emuToPx(((intAttr(off, '', 'x') ?? 0) - (intAttr(chOff, '', 'x') ?? 0)) * sx),
|
||||
y: emuToPx(((intAttr(off, '', 'y') ?? 0) - (intAttr(chOff, '', 'y') ?? 0)) * sy),
|
||||
w: framePx(emuToPx((intAttr(ext, '', 'cx') ?? 0) * sx)),
|
||||
h: framePx(emuToPx((intAttr(ext, '', 'cy') ?? 0) * sy)),
|
||||
};
|
||||
};
|
||||
|
||||
const carrier: Node[] = [];
|
||||
const over: Node[] = [];
|
||||
let first: { x: number; y: number; w: number; h: number } | null = null;
|
||||
for (const kid of Array.from(wgp.children)) {
|
||||
const isPic = kid.localName === 'pic';
|
||||
if (!isPic && !(kid.namespaceURI === WPS && kid.localName === 'wsp')) continue;
|
||||
const box = boxOf(kid);
|
||||
let node: Node | null;
|
||||
if (isPic) {
|
||||
const blip = kid.getElementsByTagNameNS(A, 'blip')[0];
|
||||
const src = blip ? blipSrc(blip, ctx) : null;
|
||||
node = src ? frameNode(src, box, '', anchor, ctx) : null;
|
||||
} else {
|
||||
node = convertWpsShape(kid, root, !!anchor, ctx, box);
|
||||
}
|
||||
if (!node) continue;
|
||||
const attrs = { ...node.attrs, width: box.w, height: box.h } as Record<string, unknown>;
|
||||
if (!first) { first = box; carrier.push({ ...node, attrs }); continue; }
|
||||
// Every other child runs through the text over the first, offset by its place in
|
||||
// the group. An inline group's origin is its paragraph's corner, so a group that
|
||||
// shares its line (a centred one, text beside it) carries the rest there.
|
||||
attrs.wrap = 'through';
|
||||
attrs.inFront = true;
|
||||
const off = (k: string, d: number) =>
|
||||
round2((typeof node.attrs?.[k] === 'number' ? (node.attrs[k] as number) : 0) + d / PX_PER_CM);
|
||||
attrs.wrapOffset = off('wrapOffset', box.x - first.x);
|
||||
attrs.wrapOffsetY = off('wrapOffsetY', box.y - first.y);
|
||||
over.push({ ...node, attrs });
|
||||
}
|
||||
if (!carrier.length) ctx.warnings.add('Drawings were removed');
|
||||
// An inline carrier is what the line reserves the group's box with, and the frames
|
||||
// over it take their static position from the paragraph — so they precede it.
|
||||
return anchor ? [...carrier, ...over] : [...over, ...carrier];
|
||||
}
|
||||
|
||||
|
||||
// theme1.xml's colour scheme by slot name, plus the text/background aliases Word's
|
||||
// default mapping gives them (a shape names `tx2`, the scheme declares `dk2`). A slot
|
||||
// defined as a system colour keeps the value the producer last resolved it to.
|
||||
@@ -2306,7 +2375,9 @@ function custGeomPath(spPr: Element | null): string {
|
||||
// A DrawingML <wps:wsp> (text box, preset shape or freeform) → a textBox node. A preset
|
||||
// `utils/shapes.ts` can't draw and has no path of its own (a connector) is dropped with a
|
||||
// warning. All property lookups are scoped to spPr so a nested image's fill/xfrm can't leak in.
|
||||
function convertWpsShape(wsp: Element, root: Element, isAnchor: boolean, ctx: Ctx): Node | null {
|
||||
// `box` overrides the drawing's own extent for a shape inside a group, whose size is
|
||||
// its place in the group rather than the whole group's.
|
||||
function convertWpsShape(wsp: Element, root: Element, isAnchor: boolean, ctx: Ctx, box?: { w: number; h: number }): Node | null {
|
||||
const spPr = nsChild(wsp, WPS, 'spPr');
|
||||
const outline = custGeomPath(spPr);
|
||||
const kind = outline ? 'textbox' : shapeFromPrst(nsChild(spPr, A, 'prstGeom')?.getAttribute('prst') ?? 'rect');
|
||||
@@ -2318,8 +2389,11 @@ function convertWpsShape(wsp: Element, root: Element, isAnchor: boolean, ctx: Ct
|
||||
const extent = root.getElementsByTagNameNS(WP, 'extent')[0];
|
||||
const cx = intAttr(extent, '', 'cx');
|
||||
const cy = intAttr(extent, '', 'cy');
|
||||
if (cx) attrs.width = framePx(emuToPx(cx));
|
||||
if (cy) attrs.height = framePx(emuToPx(cy));
|
||||
if (box) { attrs.width = box.w; attrs.height = box.h; }
|
||||
else {
|
||||
if (cx) attrs.width = framePx(emuToPx(cx));
|
||||
if (cy) attrs.height = framePx(emuToPx(cy));
|
||||
}
|
||||
const rot = intAttr(nsChild(spPr, A, 'xfrm'), '', 'rot');
|
||||
if (rot) attrs.rotation = ((Math.round(rot / 60000) % 360) + 360) % 360;
|
||||
if (isAnchor) {
|
||||
@@ -2335,6 +2409,7 @@ function convertWpsShape(wsp: Element, root: Element, isAnchor: boolean, ctx: Ct
|
||||
?.getElementsByTagNameNS(WP, 'align')[0]?.textContent?.trim();
|
||||
if ((wrap === 'topBottom' || wrap === 'through') && offsetCm == null
|
||||
&& (align === 'center' || align === 'right')) attrs.wrapAlign = align;
|
||||
if (wrap === 'through') attrs.inFront = root.getAttribute('behindDoc') !== '1';
|
||||
}
|
||||
|
||||
const fill = drawingColor(nsChild(spPr, A, 'solidFill'), ctx) ?? null;
|
||||
|
||||
@@ -14,6 +14,7 @@ export const PKG_REL = 'http://schemas.openxmlformats.org/package/2006/relations
|
||||
// Text boxes / shapes: DrawingML shape container, markup-compatibility wrapper
|
||||
// (Word wraps every shape in mc:AlternateContent), and legacy VML.
|
||||
export const WPS = 'http://schemas.microsoft.com/office/word/2010/wordprocessingShape';
|
||||
export const WPG = 'http://schemas.microsoft.com/office/word/2010/wordprocessingGroup';
|
||||
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, …).
|
||||
|
||||
Reference in new issue
Block a user