fix: a frame keeps the size its file states, to the fraction
Whole pixels cost a page-wide picture 0.3mm of height, and the column cap then scaled that loss into the width too. Sizes stay fractional (snapped back to a whole pixel inside the export's own 0.001cm quantization), and a sub-pixel overhang trims the width without touching the height — which is what LibreOffice draws.
This commit is contained in:
1 parent
3f6b64cd95
commit
a4bcaa9ded
4 files changed
+32
-19
No files matched your search
@@ -58,12 +58,25 @@ const COLUMN_WIDTH_CSS =
|
||||
|
||||
// An as-character image wider than the text column takes a line of its own and leaves an
|
||||
// empty one above it. Fitting it to the column is where an image the file sizes to the
|
||||
// column lands anyway, once its cm/EMU width is rounded to whole px (importers).
|
||||
// column lands anyway. Fractional px throughout: whole ones cost a big frame 0.3mm.
|
||||
export function fitInlineImage(attrs: Record<string, unknown>, maxWidthPx: number): void {
|
||||
const w = attrs.width;
|
||||
if (typeof w !== 'number' || w <= maxWidthPx) return;
|
||||
if (typeof attrs.height === 'number') attrs.height = Math.max(1, Math.round((attrs.height * maxWidthPx) / w));
|
||||
attrs.width = maxWidthPx;
|
||||
// Under a pixel over is our cm arithmetic disagreeing with the rendered column, not an
|
||||
// oversized picture: trim the width so the line still holds it, and keep the height —
|
||||
// LibreOffice lets that much overhang stand and draws the frame at its stated size.
|
||||
if (typeof attrs.height === 'number' && w - maxWidthPx > 1) {
|
||||
attrs.height = framePx(Math.max(1, (attrs.height * maxWidthPx) / w));
|
||||
}
|
||||
attrs.width = framePx(maxWidthPx);
|
||||
}
|
||||
|
||||
// A frame size in doc px, from the cm or EMU the file states. Kept fractional — rounding
|
||||
// a page-wide picture to whole pixels loses 0.3mm of height — but our own export
|
||||
// quantizes to 0.001cm (0.04px), so a size that close to a whole pixel *is* one.
|
||||
export function framePx(px: number): number {
|
||||
const whole = Math.round(px);
|
||||
return Math.abs(px - whole) < 0.05 ? whole : Math.round(px * 100) / 100;
|
||||
}
|
||||
|
||||
// A floating frame's margins from its wrapOffset: its left edge in the text column,
|
||||
|
||||
@@ -4,7 +4,7 @@ import { lengthToPt } 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';
|
||||
import { HEADER_SHADE } from '../editor/extensions/tableHeaderRow';
|
||||
import { fitInlineImage } from '../editor/extensions/image';
|
||||
import { fitInlineImage, framePx } from '../editor/extensions/image';
|
||||
import { formatTabStops } from '../editor/extensions/tabStops';
|
||||
import type { CapsMode, LineStyle } from '../editor/extensions/textEffects';
|
||||
import { tableLookAttr } from '../styles/tableStyles';
|
||||
@@ -1323,8 +1323,8 @@ function convertDrawing(drawing: Element, ctx: Ctx): Node | null {
|
||||
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)),
|
||||
w: framePx(emuToPx(intAttr(extentEl, '', 'cx') ?? 0)),
|
||||
h: framePx(emuToPx(intAttr(extentEl, '', 'cy') ?? 0)),
|
||||
};
|
||||
const blip = drawing.getElementsByTagNameNS(A, 'blip')[0];
|
||||
if (!blip) {
|
||||
@@ -1365,8 +1365,8 @@ function convertDrawing(drawing: Element, ctx: Ctx): Node | null {
|
||||
const extent = root.getElementsByTagNameNS(WP, 'extent')[0];
|
||||
const cx = intAttr(extent, '', 'cx');
|
||||
const cy = intAttr(extent, '', 'cy');
|
||||
if (cx) attrs.width = Math.round(emuToPx(cx));
|
||||
if (cy) attrs.height = Math.round(emuToPx(cy));
|
||||
if (cx) attrs.width = framePx(emuToPx(cx));
|
||||
if (cy) attrs.height = framePx(emuToPx(cy));
|
||||
const docPr = root.getElementsByTagNameNS(WP, 'docPr')[0];
|
||||
const alt = docPr?.getAttribute('title') || docPr?.getAttribute('descr');
|
||||
if (alt) attrs.alt = alt;
|
||||
@@ -1504,8 +1504,8 @@ 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 = Math.round(emuToPx(cx));
|
||||
if (cy) attrs.height = Math.round(emuToPx(cy));
|
||||
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) {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { StyleResolver, NS, lengthToPt, lengthToCm, layerTextProps, type PropMap
|
||||
import { HEADING_STYLE_OVERRIDES, MAX_HEADING_LEVEL, ODF_LOOK_ATTRS, normalizeColor } from '../export/odt';
|
||||
import { builtinStyleSheet, DEFAULT_STYLE, type ParaProps, type Style, type StyleSheet, type TextProps } from '../styles/styleSheet';
|
||||
import { HEADER_SHADE } from '../editor/extensions/tableHeaderRow';
|
||||
import { fitInlineImage } from '../editor/extensions/image';
|
||||
import { fitInlineImage, framePx } from '../editor/extensions/image';
|
||||
import { odfChartDataUrl } from './chart';
|
||||
import { formatTabStops, normalizeLeader } from '../editor/extensions/tabStops';
|
||||
import type { CapsMode, LineStyle } from '../editor/extensions/textEffects';
|
||||
@@ -289,8 +289,8 @@ function convertFrame(frame: Element, ctx: Ctx): Node | null {
|
||||
src = placeholderImage('Image', cmToPx(wCm), cmToPx(hCm));
|
||||
}
|
||||
const attrs: Record<string, unknown> = { src };
|
||||
if (wCm != null) attrs.width = Math.round(cmToPx(wCm));
|
||||
if (hCm != null) attrs.height = Math.round(cmToPx(hCm));
|
||||
if (wCm != null) attrs.width = framePx(cmToPx(wCm));
|
||||
if (hCm != null) attrs.height = framePx(cmToPx(hCm));
|
||||
const title = frame.getElementsByTagNameNS(NS.svg, 'title')[0]?.textContent;
|
||||
if (title) attrs.alt = title;
|
||||
applyFrameRotationAndWrap(frame, attrs, ctx.resolver.graphicProps(frame.getAttributeNS(NS.draw, 'style-name')));
|
||||
@@ -359,10 +359,10 @@ function textBoxContent(children: Element[], ctx: Ctx): Node[] {
|
||||
function convertTextBoxFrame(frame: Element, textBoxEl: Element, ctx: Ctx): Node {
|
||||
const attrs: Record<string, unknown> = {};
|
||||
const wCm = lengthToCm(frame.getAttributeNS(NS.svg, 'width'));
|
||||
if (wCm != null) attrs.width = Math.round(cmToPx(wCm));
|
||||
if (wCm != null) attrs.width = framePx(cmToPx(wCm));
|
||||
const hCm = lengthToCm(textBoxEl.getAttributeNS(NS.fo, 'min-height'))
|
||||
?? lengthToCm(frame.getAttributeNS(NS.svg, 'height'));
|
||||
if (hCm != null) attrs.height = Math.round(cmToPx(hCm));
|
||||
if (hCm != null) attrs.height = framePx(cmToPx(hCm));
|
||||
const gp = ctx.resolver.graphicProps(frame.getAttributeNS(NS.draw, 'style-name'));
|
||||
applyFrameRotationAndWrap(frame, attrs, gp);
|
||||
// A box is a block of its own here, so the centring of a figure frame — which its
|
||||
@@ -407,7 +407,7 @@ function convertChartFrame(frame: Element, ctx: Ctx): Node | null {
|
||||
const doc = loadObjectDoc(obj.getAttributeNS(NS.xlink, 'href'), ctx);
|
||||
const src = doc && odfChartDataUrl(doc, cmToPx(wCm), cmToPx(hCm));
|
||||
if (!src) return null;
|
||||
const attrs: Record<string, unknown> = { src, width: Math.round(cmToPx(wCm)), height: Math.round(cmToPx(hCm)), alt: 'Chart' };
|
||||
const attrs: Record<string, unknown> = { src, width: framePx(cmToPx(wCm)), height: framePx(cmToPx(hCm)), alt: 'Chart' };
|
||||
applyFrameRotationAndWrap(frame, attrs, ctx.resolver.graphicProps(frame.getAttributeNS(NS.draw, 'style-name')));
|
||||
if (!attrs.wrap || attrs.wrap === 'inline') fitInlineImage(attrs, Math.floor(cmToPx(ctx.contentWidthCm)));
|
||||
return { type: 'image', attrs };
|
||||
@@ -466,8 +466,8 @@ function convertShape(el: Element, ctx: Ctx): Node | null {
|
||||
if (kind !== 'textbox') attrs.shapeKind = kind;
|
||||
const wCm = lengthToCm(el.getAttributeNS(NS.svg, 'width'));
|
||||
const hCm = lengthToCm(el.getAttributeNS(NS.svg, 'height'));
|
||||
if (wCm != null) attrs.width = Math.round(cmToPx(wCm));
|
||||
if (hCm != null) attrs.height = Math.round(cmToPx(hCm));
|
||||
if (wCm != null) attrs.width = framePx(cmToPx(wCm));
|
||||
if (hCm != null) attrs.height = framePx(cmToPx(hCm));
|
||||
const gp = ctx.resolver.graphicProps(el.getAttributeNS(NS.draw, 'style-name'));
|
||||
applyFrameRotationAndWrap(el, attrs, gp);
|
||||
shapeStyleAttrs(gp, attrs, true);
|
||||
|
||||
Reference in new issue
Block a user