Skip to content

Commit 40af0b3

Browse files
tthallosclaude
andcommitted
fix(renderer): keep text glyphs isotropic under non-uniform group scale
When an OOXML group has `ext` differing from `chExt`, the group applies a non-uniform scale to its descendants. PowerPoint stretches the shape frame but keeps text glyphs isotropic; pagus was propagating the scale into each shape's <foreignObject>, horizontally stretching the text. Thread a cumulative `AccumScale` from `renderGroup` down through `renderElement` into shape, text, and table renderers. When the accumulated scale is non-uniform, wrap the foreignObject in an inverse scale of `min(Sx,Sy)/Sx, min(Sx,Sy)/Sy` and expand its intrinsic width/height by the reciprocal so the compensated box still fills the frame. Uniform / identity scale is a no-op. Refs #20 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 96a65ac commit 40af0b3

4 files changed

Lines changed: 83 additions & 23 deletions

File tree

packages/renderer/src/ShapeRenderer.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import type { ShapeElement, LineEnd } from '@pagus-kit/core'
66
import { generateShapePath } from '@pagus-kit/core'
77
import { renderFill } from './FillRenderer'
88
import { renderText } from './TextRenderer'
9+
import type { AccumScale } from './SlideRenderer'
910
import { fmt, ptToPx, dashArray } from './utils/svg'
1011

1112
const LINE_END_SIZE: Record<string, number> = { small: 3, medium: 5, large: 8 }
@@ -90,6 +91,7 @@ export function renderShape(
9091
genId: () => string,
9192
fontSubs?: Record<string, string>,
9293
hiddenParagraphs?: Set<number>,
94+
accumScale?: AccumScale,
9395
): string {
9496
const w = shape.size.width
9597
const h = shape.size.height
@@ -149,7 +151,7 @@ export function renderShape(
149151
// Text (foreignObject) — NOT flipped, stays readable
150152
let textEl = ''
151153
if (shape.textBody && shape.textBody.paragraphs.length > 0) {
152-
textEl = renderText(shape.textBody, shape.size, fontSubs, hiddenParagraphs)
154+
textEl = renderText(shape.textBody, shape.size, fontSubs, hiddenParagraphs, accumScale)
153155
}
154156

155157
if (textEl || isLine) {

packages/renderer/src/SlideRenderer.ts

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -115,13 +115,29 @@ function buildShadowFilter(shadow: Shadow, defs: string[], genId: () => string):
115115

116116
// ---- Element rendering dispatch ----
117117

118+
/**
119+
* Accumulated group scale from the root down to this element.
120+
* When a group's outer `ext` differs from its inner `chExt`, the group applies
121+
* a non-uniform scale to its descendants. Text glyphs should NOT inherit that
122+
* non-uniformity (PowerPoint stretches the frame, not the glyphs), so we
123+
* thread the cumulative scale down and let text-emitting renderers cancel the
124+
* non-uniform component via an inverse transform.
125+
*/
126+
export interface AccumScale {
127+
x: number
128+
y: number
129+
}
130+
131+
const IDENTITY_SCALE: AccumScale = { x: 1, y: 1 }
132+
118133
function renderElement(
119134
el: SlideElement,
120135
defs: string[],
121136
genId: () => string,
122137
slideSize: Size,
123138
fontSubs?: Record<string, string>,
124139
hiddenIds?: string[],
140+
accumScale: AccumScale = IDENTITY_SCALE,
125141
): string {
126142
const transform = buildTransformAttr(el, slideSize.width, slideSize.height)
127143

@@ -144,7 +160,7 @@ function renderElement(
144160

145161
switch (el.type) {
146162
case 'shape':
147-
content = renderShape(el, defs, genId, fontSubs, hiddenParagraphs)
163+
content = renderShape(el, defs, genId, fontSubs, hiddenParagraphs, accumScale)
148164
break
149165
case 'image':
150166
content = renderImage(el, defs, genId)
@@ -153,13 +169,13 @@ function renderElement(
153169
content = renderMedia(el)
154170
break
155171
case 'table':
156-
content = renderTable(el, fontSubs)
172+
content = renderTable(el, fontSubs, accumScale)
157173
break
158174
case 'chart':
159175
content = renderChart(el)
160176
break
161177
case 'group':
162-
content = renderGroup(el, defs, genId, slideSize, fontSubs, hiddenIds)
178+
content = renderGroup(el, defs, genId, slideSize, fontSubs, hiddenIds, accumScale)
163179
break
164180
default:
165181
content = ''
@@ -186,6 +202,7 @@ function renderGroup(
186202
slideSize: Size,
187203
fontSubs?: Record<string, string>,
188204
hiddenIds?: string[],
205+
accumScale: AccumScale = IDENTITY_SCALE,
189206
): string {
190207
// Group applies a coordinate space transformation for its children
191208
// childOffset and childExtent define the source coordinate space
@@ -212,8 +229,12 @@ function renderGroup(
212229
innerTransform = `translate(${offsetX},${offsetY})`
213230
}
214231

232+
const childAccumScale: AccumScale = hasScale
233+
? { x: accumScale.x * scaleX, y: accumScale.y * scaleY }
234+
: accumScale
235+
215236
const childrenSvg = group.children
216-
.map((child) => renderElement(child, defs, genId, slideSize, fontSubs, hiddenIds))
237+
.map((child) => renderElement(child, defs, genId, slideSize, fontSubs, hiddenIds, childAccumScale))
217238
.join('')
218239

219240
if (innerTransform) {

packages/renderer/src/TableRenderer.ts

Lines changed: 26 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import type { TableElement, TableCell, BorderLine } from '@pagus-kit/core'
66
import { fillToCss } from './FillRenderer'
77
import { renderTextInline } from './TextRenderer'
8+
import type { AccumScale } from './SlideRenderer'
89
import { fmt, ptToPx, cssStyle } from './utils/svg'
910

1011
/** Mix a hex color toward white by the given ratio (0 = white, 1 = full color). */
@@ -86,7 +87,7 @@ function renderCell(cell: TableCell, colWidthPx: number, rowHeightPx: number, hi
8687
* @param table - The table element from the IR
8788
* @returns SVG foreignObject string containing an HTML table
8889
*/
89-
export function renderTable(table: TableElement, fontSubs?: Record<string, string>): string {
90+
export function renderTable(table: TableElement, fontSubs?: Record<string, string>, accumScale?: AccumScale): string {
9091
const wpx = ptToPx(table.size.width)
9192
const hpx = ptToPx(table.size.height)
9293

@@ -142,14 +143,28 @@ export function renderTable(table: TableElement, fontSubs?: Record<string, strin
142143
'table-layout': 'fixed',
143144
})
144145

145-
return [
146-
`<foreignObject x="0" y="0" width="${fmt(wpx)}" height="${fmt(hpx)}">`,
147-
`<table xmlns="http://www.w3.org/1999/xhtml" style="${tableStyle}">`,
148-
colgroup,
149-
`<tbody>`,
150-
rowsHtml,
151-
`</tbody>`,
152-
`</table>`,
153-
`</foreignObject>`,
154-
].join('')
146+
// Non-uniform group scale compensation (see TextRenderer for rationale).
147+
const sx = accumScale?.x ?? 1
148+
const sy = accumScale?.y ?? 1
149+
const needsCompensation = sx !== sy && sx > 0 && sy > 0
150+
const s = needsCompensation ? Math.min(sx, sy) : 1
151+
const invX = needsCompensation ? s / sx : 1
152+
const invY = needsCompensation ? s / sy : 1
153+
const expX = needsCompensation ? sx / s : 1
154+
const expY = needsCompensation ? sy / s : 1
155+
156+
const fo =
157+
`<foreignObject x="0" y="0" width="${fmt(wpx * expX)}" height="${fmt(hpx * expY)}">` +
158+
`<table xmlns="http://www.w3.org/1999/xhtml" style="${tableStyle}">` +
159+
colgroup +
160+
`<tbody>` +
161+
rowsHtml +
162+
`</tbody>` +
163+
`</table>` +
164+
`</foreignObject>`
165+
166+
if (needsCompensation) {
167+
return `<g transform="scale(${fmt(invX)},${fmt(invY)})">${fo}</g>`
168+
}
169+
return fo
155170
}

packages/renderer/src/TextRenderer.ts

Lines changed: 29 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
*/
44

55
import type { TextBody, TextRun, Paragraph, Size, ResolvedColor, LineSpacing } from '@pagus-kit/core'
6+
import type { AccumScale } from './SlideRenderer'
67
import { escapeHtml, ptToPx, fmt, cssStyle } from './utils/svg'
78

89
function colorToCss(color: ResolvedColor): string {
@@ -366,6 +367,7 @@ export function renderText(
366367
containerSize: Size,
367368
fontSubs?: Record<string, string>,
368369
hiddenParagraphs?: Set<number>,
370+
accumScale?: AccumScale,
369371
): string {
370372
const { bodyProperties, paragraphs } = textBody
371373
const { anchor, margins } = bodyProperties
@@ -453,13 +455,33 @@ export function renderText(
453455

454456
const shrinkAttr = isShrinkNoScale ? ' data-pagus-shrink-fit="1"' : ''
455457

456-
return [
457-
`<foreignObject x="0" y="${fmt(foY)}" width="${fmt(containerWPx)}" height="${fmt(foHeight)}" overflow="visible">`,
458-
`<div xmlns="http://www.w3.org/1999/xhtml"${shrinkAttr} style="${wrapperStyle}">`,
459-
html,
460-
`</div>`,
461-
`</foreignObject>`,
462-
].join('')
458+
// Non-uniform group scale compensation.
459+
// PowerPoint stretches the shape frame but keeps text glyphs isotropic. SVG
460+
// propagates a non-uniform parent scale into the foreignObject HTML, which
461+
// would horizontally (or vertically) distort text. To neutralize the
462+
// non-uniform component while preserving the overall size chosen by the
463+
// smaller axis, we wrap the foreignObject in an inverse scale and expand
464+
// its intrinsic dimensions so the post-wrap box still fills the frame.
465+
const sx = accumScale?.x ?? 1
466+
const sy = accumScale?.y ?? 1
467+
const needsCompensation = sx !== sy && sx > 0 && sy > 0
468+
const s = needsCompensation ? Math.min(sx, sy) : 1
469+
const invX = needsCompensation ? s / sx : 1
470+
const invY = needsCompensation ? s / sy : 1
471+
const expX = needsCompensation ? sx / s : 1
472+
const expY = needsCompensation ? sy / s : 1
473+
474+
const fo =
475+
`<foreignObject x="0" y="${fmt(foY * expY)}" width="${fmt(containerWPx * expX)}" height="${fmt(foHeight * expY)}" overflow="visible">` +
476+
`<div xmlns="http://www.w3.org/1999/xhtml"${shrinkAttr} style="${wrapperStyle}">` +
477+
html +
478+
`</div>` +
479+
`</foreignObject>`
480+
481+
if (needsCompensation) {
482+
return `<g transform="scale(${fmt(invX)},${fmt(invY)})">${fo}</g>`
483+
}
484+
return fo
463485
}
464486

465487
/**

0 commit comments

Comments
 (0)