Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -260,14 +260,16 @@ Oracle case naming:

Case JSON (VBA pipeline): `test/e2e/oracle/cases-full/*.json`. Each declares `kind: “shape”` or `kind: “smartart”` with layout/dimensions.

Python-pptx pipeline cases: `test/e2e/oracle/cases-pypptx/*.json` (202 cases: 60 text,
Python-pptx pipeline cases: `test/e2e/oracle/cases-pypptx/*.json` (204 cases: 62 text,
31 shape-adjustment, 28 zero-adjustment flowchart, 2 shape-effect, 1 text-effect, 20 static 3D,
8 table, 8 formula, 20 composite, and 24 chart).
Prefix `oracle-pypptx-{category}-{NNNN}-{slug}` where category is `text`, `shape-adj`,
`flowchart`, `shape-effect`, `text-effect`, `shape3d`, `table`, `formula`, `composite`, or `chart`. Generated by
`test/e2e/scripts/generate_pypptx_cases.py`; `--case` accepts repeatable exact/glob filters. Text
IDs 0040-0055 are the CJK wrap/autofit/spacing interaction matrix; IDs 0056-0059 cover
`defRPr`/`fontRef` text-color precedence.
`defRPr`/`fontRef` text-color precedence; ID 0060 covers styled soft breaks; ID 0061 covers the
native tab-stop matrix; ID 0062 covers every non-horizontal DrawingML vertical mode, anchors,
stacked-character advance, and mixed CJK/Hangul fallback.

### Step 2: Identify the Shape

Expand Down Expand Up @@ -682,7 +684,7 @@ Report: `test/e2e/reports/oracle-failures/full-ground-truth-one-shot.json`
| `scripts/run_all_shapes_eval.py` | `--shape-id-min N --shape-id-max N` | Batch evaluate shapes via POST `/api/evaluate/{case}` |
| `scripts/analyze_edge.py` | `<case> [--slide N]` | Canny edge IoU analysis with visual overlay output |
| `scripts/one_shot_full_ground_truth.py` | `--macro-host ... --cases-dir ...` | Bulk generate PPTX+PDF ground truth from oracle cases |
| `scripts/generate_pypptx_cases.py` | `[--case PATTERN]` | Generate 181 python-pptx cases across the documented matrices |
| `scripts/generate_pypptx_cases.py` | `[--case PATTERN]` | Generate 204 python-pptx cases across the documented matrices |
| `scripts/reflection_metrics.py` | `--case-report PATH` | Validate local reflection fields and erasure sensitivity |

Key mechanics:
Expand Down
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@ and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.ht

## [Unreleased]

### Fixed

- Fixed explicit text tabs, all DrawingML vertical text flows, stacked WordArt spacing, CJK/Korean
font fallback, and stretched picture-filled runs, including lazy package media.

## [1.3.0] - 2026-09-14

### Added
Expand Down
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -570,8 +570,10 @@ geometry are not yet supported.

Text rendering follows the master, layout, placeholder, shape, paragraph, and run cascade. It
supports theme fonts, CJK text, bullets, hyperlinks, vertical text, superscript/subscript, Office
percentage spacing, common wrap/overflow combinations, and selected `spAutoFit` growth. Font
availability remains part of visual-test provenance.
percentage spacing, explicit left/center/right/decimal tab stops in horizontal left-to-right text,
and explicit left tab stops in vertical East Asian text,
solid/gradient/pattern/stretched-picture run fills, common wrap/overflow combinations, and selected
`spAutoFit` growth. Font availability remains part of visual-test provenance.

### Tables

Expand Down
23 changes: 18 additions & 5 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,18 @@ host page's `white-space` rule cannot replace the presentation semantics. Explic
`horzOverflow` and `vertOverflow` values are resolved independently. Paragraph `eaLnBrk="0"`
uses the browser's unrestricted break opportunity, while the omitted/true default retains East
Asian typographic rules; both values are explicit so inherited host CSS cannot change the result.
For horizontal left-to-right paragraphs, explicit `a:tabLst/a:tab@pos` targets are resolved after
fonts load from the current browser-laid-out cursor. This preserves the text-frame-relative OOXML
position across paragraph margins, first-line indents, inline or mixed-run tabs, multiple stops,
and bullet gutters. Left, center, right, and decimal alignments use the measured following field;
paragraphs without `a:tabLst` retain the browser `tab-size` path. Right-to-left and vertical text
also retain that fallback, except for the native-verified East Asian vertical left-tab lane, which
measures the stop on the vertical inline axis.

For `a:bodyPr@vert`, the renderer maps all six non-horizontal DrawingML values to their matching
column direction and glyph orientation. Stacked WordArt adds PowerPoint's character advance, and
the CJK sans fallback stack prefers Korean platform fonts before the generic Unicode fallback so
mixed CJK/Hangul vertical text keeps native-like glyph metrics and column breaks.

The three autofit choices remain mutually exclusive:

Expand All @@ -248,14 +260,15 @@ whose size comes only from inheritance remain on the bounded fit path. The nativ
therefore a finite text-box cohort, not a claim of editor-level parity for every PowerPoint autofit
context.

### Text color precedence
### Text fill precedence

`TextRenderer` resolves run styles through the existing master/layout/shape/paragraph/run cascade,
then applies container color options with an explicit local-precedence check. A color declared on
then applies container color options with an explicit local-precedence check. A fill declared on
the run wins first; otherwise a `solidFill` on paragraph `defRPr` wins over the shape's resolved
`fontRef`. The shape `fontRef` is used only when both local levels omit a text fill. The same path
handles direct `srgbClr` and theme-backed `schemeClr`, so theme lookup remains in `StyleResolver`
instead of being duplicated by the precedence layer.
`fontRef`. The shape `fontRef` is used only when both local levels omit a text fill. Solid, gradient,
pattern, stretched-picture, and no-fill choices remain mutually exclusive. Stretched picture fills
resolve embedded or lazy media through the current part's relationships and clip the image to the
run glyphs. Direct `srgbClr` and theme-backed `schemeClr` still resolve in `StyleResolver`.

The native matrix includes positive paragraph defaults, an explicit run override, the no-local-color
inverse fallback, and square, wide, and tall containers. This boundary verifies color selection; it
Expand Down
13 changes: 11 additions & 2 deletions docs/TESTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -282,7 +282,7 @@ This generates/reuses ground truth for all SmartArt layouts available on the loc

For text, shape-adjustment, zero-adjustment flowchart, ordinary-shape and text effects, bounded
static DrawingML 3D, table, composite, and chart interaction cases, use the python-pptx generator.
It currently defines 202 cases: 60 text, 31 shape-adjustment, 28 flowchart, 2 shape-effect, 1
It currently defines 204 cases: 62 text, 31 shape-adjustment, 28 flowchart, 2 shape-effect, 1
text-effect, 20 static 3D, 8 table, 8 formula, 20 composite, and 24 chart cases. Chart cases 23–24
exercise readable two-dimensional fallbacks for native 3D column and pie charts; they do not claim
three-dimensional parity.
Expand Down Expand Up @@ -368,6 +368,15 @@ Text IDs 0056-0059 cover color precedence independently from layout behavior: pa
`srgbClr` and `schemeClr` over a conflicting shape `fontRef`, an explicit run color over both, and
the inverse fallback to `fontRef` when local fills are absent. The cases span square, wide, and tall
containers and retain exact color values in their tracked OOXML metadata.
ID 0060 isolates styled soft breaks from the visible run's metrics and bullet color. ID 0061 is an
eleven-slide native tab-stop matrix covering leading and inline left tabs, multiple stops, bullets,
mixed-run tabs, the default interval, explicit center/right/decimal alignment, and a vertical East
Asian left tab. The vertical row uses a horizontal guide on the inline axis and is also guarded by
browser coordinate assertions for column centering and the 2-inch advance. Its RTL slide remains an
observation control.
ID 0062 is a nine-slide native vertical-text matrix covering `eaVert`, `mongolianVert`, `vert`,
`vert270`, `wordArtVert`, and `wordArtVertRtl`, plus top/center/bottom anchors, 12/24/36 pt stacked
character advance, and mixed CJK/Hangul fallback behavior.

```bash
cd test/e2e
Expand All @@ -377,7 +386,7 @@ cd test/e2e

# Generate the focused text matrices. --case is repeatable and accepts exact names or globs.
.venv/bin/python scripts/generate_pypptx_cases.py \
--case 'oracle-pypptx-text-00[45]*'
--case 'oracle-pypptx-text-00[456]*'

# Generate only the bounded static DrawingML 3D matrix.
.venv/bin/python scripts/generate_pypptx_cases.py \
Expand Down
37 changes: 28 additions & 9 deletions src/renderer/ShapeRenderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,7 @@ import {
resolveThemeFillReference,
getFocusedGradientStops,
} from './StyleResolver';
import { renderTextBody, resolveTextFields } from './TextRenderer';
import { renderTextBody, resolveTextFields, type DrawingMLVerticalTextMode } from './TextRenderer';
import { renderCustomGeometry } from '../shapes/customGeometry';
import {
getPresetShapePath,
Expand Down Expand Up @@ -284,14 +284,19 @@ function expandCssLengthForScale(length: string, scale: number): string {
function applyVerticalTextFlow(
el: HTMLElement,
anchor: string | null | undefined,
upright = false,
textOrientation?: 'upright' | 'sideways',
writingMode: 'vertical-rl' | 'vertical-lr' = 'vertical-rl',
): void {
el.style.writingMode = writingMode;
el.style.justifyContent = 'center';
el.style.alignItems = anchor === 'b' ? 'flex-end' : anchor === 'ctr' ? 'center' : 'flex-start';
if (upright) {
el.style.textOrientation = 'upright';
// In vertical writing the DrawingML anchor maps to the horizontal block axis.
// The inline axis still begins at the physical top of the text frame.
el.style.justifyContent =
anchor === 'b' ? 'flex-end' : anchor === 'ctr' ? 'center' : 'flex-start';
el.style.alignItems = 'flex-start';
if (textOrientation) {
el.style.textOrientation = textOrientation;
}
if (textOrientation === 'upright') {
el.style.whiteSpace = 'normal';
}
}
Expand Down Expand Up @@ -3056,6 +3061,7 @@ export function renderShape(node: ShapeNodeData, ctx: RenderContext): HTMLElemen
}

let isVerticalText = false;
let verticalTextMode: DrawingMLVerticalTextMode | undefined;
let textAnchor: string | null | undefined;
const isSingleLineSpAutoFit =
!!hasSpAutoFit && !hasNormAutofit && isSingleLineTextBody(textBody);
Expand Down Expand Up @@ -3127,16 +3133,28 @@ export function renderShape(node: ShapeNodeData, ctx: RenderContext): HTMLElemen
if (vert === 'eaVert') {
applyVerticalTextFlow(textContainer, textAnchor);
isVerticalText = true;
verticalTextMode = vert;
} else if (vert === 'mongolianVert') {
applyVerticalTextFlow(textContainer, textAnchor, undefined, 'vertical-lr');
isVerticalText = true;
verticalTextMode = vert;
} else if (vert === 'wordArtVert') {
applyVerticalTextFlow(textContainer, textAnchor, true, 'vertical-lr');
applyVerticalTextFlow(textContainer, textAnchor, 'upright', 'vertical-lr');
isVerticalText = true;
verticalTextMode = vert;
} else if (vert === 'wordArtVertRtl') {
applyVerticalTextFlow(textContainer, textAnchor, 'upright');
isVerticalText = true;
verticalTextMode = vert;
} else if (vert === 'vert') {
applyVerticalTextFlow(textContainer, textAnchor);
applyVerticalTextFlow(textContainer, textAnchor, 'sideways');
isVerticalText = true;
verticalTextMode = vert;
} else if (vert === 'vert270') {
applyVerticalTextFlow(textContainer, textAnchor);
applyVerticalTextFlow(textContainer, textAnchor, 'sideways');
appendTransform(textContainer, 'rotate(180deg)');
isVerticalText = true;
verticalTextMode = vert;
}

if (
Expand Down Expand Up @@ -3183,6 +3201,7 @@ export function renderShape(node: ShapeNodeData, ctx: RenderContext): HTMLElemen
: OFFICE_SINGLE_PARAGRAPH_LINE_HEIGHT,
...(fontRefColor ? { fontRefColor } : {}),
...(isVerticalText ? { isVerticalText } : {}),
...(verticalTextMode ? { verticalTextMode } : {}),
...(hasSpAutoFit && !hasNormAutofit
? (() => {
const hasExplicitSpacing = hasExplicitParagraphSpacing(textBody);
Expand Down
Loading
Loading