feat: keep the space a chart or a metafile reserves
A drawing the editor cannot render was dropped outright, and everything below it moved up: a thesis with five charts and four EMF figures lost 102cm of layout, five whole pages of it. They now come in as a labelled box of the drawing's own size, so the pages hold what they hold in Word.
This commit is contained in:
1 parent
b217de06c1
commit
09f57c5326
3 files changed
+54
-7
No files matched your search
+34
-5
@@ -10,7 +10,7 @@ import { tableLookAttr } from '../styles/tableStyles';
|
||||
import { orderedTypeFromFormat, orderedTypeAttrAt, childCycle, ROOT_ORDERED_CYCLE, type OrderedCycle } from '../utils/orderedListTypes';
|
||||
import { bulletCharAttr, bulletCharFromDocx } from '../utils/bulletListTypes';
|
||||
import { DATE_FORMATS, TIME_FORMATS, docxPicture, toDateValue } from '../utils/dateTime';
|
||||
import { imageDataUrl, type ConvertedImages } from './imageFormats';
|
||||
import { imageDataUrl, placeholderImage, type ConvertedImages } from './imageFormats';
|
||||
import { PX_PER_CM, cmToPx, type PageMargins } from '../storage/pageMargins';
|
||||
import type { Orientation } from '../storage/pageOrientation';
|
||||
import { formatFromCm, type PageFormat } from '../storage/pageFormat';
|
||||
@@ -1135,8 +1135,22 @@ function convertDrawing(drawing: Element, ctx: Ctx): Node | null {
|
||||
// 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);
|
||||
const extentEl = root.getElementsByTagNameNS(WP, 'extent')[0];
|
||||
const boxPx = {
|
||||
w: Math.round(emuToPx(intAttr(extentEl, '', 'cx') ?? 0)),
|
||||
h: Math.round(emuToPx(intAttr(extentEl, '', 'cy') ?? 0)),
|
||||
};
|
||||
const blip = drawing.getElementsByTagNameNS(A, 'blip')[0];
|
||||
if (!blip) { ctx.warnings.add('Drawings were removed'); return null; }
|
||||
if (!blip) {
|
||||
// A chart (or any other graphic the editor cannot draw) still occupies its box, so
|
||||
// it comes in as a placeholder of that size rather than collapsing the layout.
|
||||
if (boxPx.w > 0 && boxPx.h > 0) {
|
||||
ctx.warnings.add('Charts and other drawings were replaced by a placeholder');
|
||||
return placeholderNode(boxPx, 'Chart', anchor, ctx);
|
||||
}
|
||||
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[] = [];
|
||||
@@ -1151,9 +1165,11 @@ function convertDrawing(drawing: Element, ctx: Ctx): Node | null {
|
||||
const path = candidates.find(p => loadImageDataUrl(p, ctx)) ?? candidates[0];
|
||||
const src = loadImageDataUrl(path, ctx);
|
||||
if (!src) {
|
||||
ctx.warnings.add(ctx.files[path]
|
||||
? 'Images in a format the browser can’t display (e.g. WMF, EMF, TIFF) were removed'
|
||||
: 'Some images could not be read and were skipped');
|
||||
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');
|
||||
return placeholderNode(boxPx, 'Image', anchor, ctx);
|
||||
}
|
||||
ctx.warnings.add('Some images could not be read and were skipped');
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1183,6 +1199,19 @@ function convertDrawing(drawing: Element, ctx: Ctx): Node | null {
|
||||
// The wrap mode, plus where the frame sits in the text column (cm from its left edge,
|
||||
// null when the file only names a side). Text can only flow on one side of a CSS float,
|
||||
// so the side is the half of the column the frame does *not* fill.
|
||||
// An undrawable frame as an image node of the same size, wrapped like the real one.
|
||||
function placeholderNode(box: { w: number; h: number }, label: string, anchor: Element | undefined, ctx: Ctx): Node {
|
||||
const attrs: Record<string, unknown> = { src: placeholderImage(label, box.w, box.h), width: box.w, height: box.h, alt: label };
|
||||
if (anchor) {
|
||||
const { wrap, offsetCm } = anchorWrap(anchor, ctx);
|
||||
attrs.wrap = wrap;
|
||||
if (offsetCm != null) attrs.wrapOffset = offsetCm;
|
||||
} else {
|
||||
fitInlineImage(attrs, Math.floor(cmToPx(ctx.contentWidthCm)));
|
||||
}
|
||||
return { type: 'image', attrs };
|
||||
}
|
||||
|
||||
function anchorWrap(anchor: Element, ctx: Ctx): { wrap: 'left' | 'right' | 'topBottom'; offsetCm: number | null } {
|
||||
if (anchor.getElementsByTagNameNS(WP, 'wrapTopAndBottom')[0]) return { wrap: 'topBottom', offsetCm: null };
|
||||
const sq = anchor.getElementsByTagNameNS(WP, 'wrapSquare')[0];
|
||||
|
||||
@@ -140,3 +140,16 @@ export async function convertUnsupportedImages(bytes: Uint8Array): Promise<Conve
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// A frame whose picture cannot be shown — a chart, a metafile — as a labelled box at
|
||||
// the drawing's own size, so the document keeps the space it reserves for it.
|
||||
export function placeholderImage(label: string, widthPx: number, heightPx: number): string {
|
||||
const w = Math.max(8, Math.round(widthPx));
|
||||
const h = Math.max(8, Math.round(heightPx));
|
||||
const text = label.replace(/[<>&]/g, '');
|
||||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}">`
|
||||
+ `<rect x="0.5" y="0.5" width="${w - 1}" height="${h - 1}" fill="#f4f4f5" stroke="#c2c2c8" stroke-dasharray="6 4"/>`
|
||||
+ `<text x="${w / 2}" y="${h / 2}" fill="#8a8a90" font-family="sans-serif" font-size="13"`
|
||||
+ ` text-anchor="middle" dominant-baseline="middle">${text}</text></svg>`;
|
||||
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
|
||||
}
|
||||
@@ -762,7 +762,7 @@ describe('DOCX import of foreign text boxes / shapes', () => {
|
||||
expect(walk(b, 'text')[0].text).toBe('vml text');
|
||||
});
|
||||
|
||||
it('drops unsupported shapes / blip-less drawings with accurate warnings', () => {
|
||||
it('drops unsupported shapes, and keeps a chart as a placeholder of its size', () => {
|
||||
const documentXml = `<?xml version="1.0"?><w:document ${W} ${WPNS} ${ANS} ${WPSNS}><w:body>
|
||||
<w:p><w:r><w:drawing><wp:inline distT="0" distB="0" distL="0" distR="0">
|
||||
<wp:extent cx="914400" cy="914400"/><wp:docPr id="1" name="Star"/>
|
||||
@@ -779,7 +779,12 @@ describe('DOCX import of foreign text boxes / shapes', () => {
|
||||
const result = importDocx(zipSync({ 'word/document.xml': strToU8(documentXml) }));
|
||||
expect(walk(result.content as N, 'textBox').length).toBe(0);
|
||||
expect(result.warnings).toContain('Unsupported shapes were removed');
|
||||
expect(result.warnings).toContain('Drawings were removed');
|
||||
expect(result.warnings).toContain('Charts and other drawings were replaced by a placeholder');
|
||||
// The chart keeps the box it reserves (914400 EMU = 96px), so pagination holds.
|
||||
const ph = walk(result.content as N, 'image');
|
||||
expect(ph.length).toBe(1);
|
||||
expect(ph[0].attrs.width).toBe(96);
|
||||
expect(ph[0].attrs.height).toBe(96);
|
||||
// The wrong "images could not be read" warning must NOT appear for shapes.
|
||||
expect(result.warnings).not.toContain('Some images could not be read and were skipped');
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user