diff --git a/AGENTS.md b/AGENTS.md index dbec28f..5d77f21 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -260,7 +260,7 @@ 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` (204 cases: 62 text, +Python-pptx pipeline cases: `test/e2e/oracle/cases-pypptx/*.json` (205 cases: 63 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`, @@ -269,7 +269,8 @@ Prefix `oracle-pypptx-{category}-{NNNN}-{slug}` where category is `text`, `shape IDs 0040-0055 are the CJK wrap/autofit/spacing interaction matrix; IDs 0056-0059 cover `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. +stacked-character advance, and mixed CJK/Hangul fallback; ID 0063 covers duplicate unlinked +placeholder indices and type-based layout inheritance. ### Step 2: Identify the Shape @@ -684,7 +685,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` | ` [--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 204 python-pptx cases across the documented matrices | +| `scripts/generate_pypptx_cases.py` | `[--case PATTERN]` | Generate 205 python-pptx cases across the documented matrices | | `scripts/reflection_metrics.py` | `--case-report PATH` | Validate local reflection fields and erasure sensitivity | Key mechanics: diff --git a/CHANGELOG.md b/CHANGELOG.md index e9c572c..9e2449b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,8 @@ and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.ht ### 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. + font fallback, stretched picture-filled runs including lazy package media, and placeholder style + inheritance when layouts reuse an unlinked placeholder index. ## [1.3.0] - 2026-09-14 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 155b95a..1a18c2e 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -87,6 +87,11 @@ Responsibilities: serialization consumes that slide. - Build model-level text indexes and search results that are independent of mounted DOM. +Slide placeholders with an ordinary explicit `idx` inherit from the layout placeholder with that +index. A missing `idx`, or the unlinked sentinel `4294967295`, instead matches by the effective +placeholder type so duplicate sentinel values cannot select an unrelated layout style. Layout to +master inheritance remains type based. + ## 3) Render Layer Core modules: diff --git a/docs/TESTING.md b/docs/TESTING.md index 4e9fe6d..cdfd2e8 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -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 204 cases: 62 text, 31 shape-adjustment, 28 flowchart, 2 shape-effect, 1 +It currently defines 205 cases: 63 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. @@ -377,6 +377,8 @@ 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. +ID 0063 reproduces duplicate unlinked placeholder indices and verifies that body geometry and text +styles resolve by placeholder type instead of layout order. ```bash cd test/e2e diff --git a/src/model/Presentation.ts b/src/model/Presentation.ts index 5b8ee82..2d3384a 100644 --- a/src/model/Presentation.ts +++ b/src/model/Presentation.ts @@ -3,7 +3,11 @@ * (themes, masters, layouts, slides) into a single PresentationData structure. */ -import { masterPlaceholderType } from './placeholderMatching'; +import { + findMatchingLayoutPlaceholder, + findMatchingMasterPlaceholder, + getPlaceholderInfo, +} from './placeholderMatching'; import { PptxFiles } from '../parser/ZipParser'; import type { MediaResolver } from '../utils/media'; import { parseXml, SafeXmlNode } from '../parser/XmlParser'; @@ -420,28 +424,6 @@ export function buildPresentation( // Placeholder Position Inheritance // --------------------------------------------------------------------------- -/** - * Extract placeholder info (type, idx) from a raw placeholder XML node - * stored in layout/master. - */ -function getPhInfo(phNode: SafeXmlNode): { type?: string; idx?: number } { - // Try nvSpPr > nvPr > ph, or nvPicPr > nvPr > ph - for (const wrapper of ['nvSpPr', 'nvPicPr', 'nvGrpSpPr', 'nvGraphicFramePr', 'nvCxnSpPr']) { - const nvWrapper = phNode.child(wrapper); - if (nvWrapper.exists()) { - const nvPr = nvWrapper.child('nvPr'); - const ph = nvPr.child('ph'); - if (ph.exists()) { - const type = ph.attr('type'); - const idxStr = ph.attr('idx'); - const idx = idxStr !== undefined ? Number(idxStr) : undefined; - return { type, idx: idx !== undefined && !isNaN(idx) ? idx : undefined }; - } - } - } - return {}; -} - /** * Extract xfrm position/size from a raw placeholder XML node. */ @@ -464,17 +446,6 @@ function getPhXfrm(phNode: SafeXmlNode): { position: Position; size: Size } | un return undefined; } -/** - * Find a matching layout placeholder (PlaceholderEntry); use entry.absoluteXfrm when present. - */ -function findMatchingLayoutPlaceholder( - placeholders: PlaceholderEntry[], - idx?: number, -): PlaceholderEntry | undefined { - // Slide placeholders inherit from layout by idx (default 0), regardless of type. - return placeholders.find((entry) => (getPhInfo(entry.node).idx ?? 0) === (idx ?? 0)); -} - function getMasterPlaceholderEntries(master: MasterData): PlaceholderEntry[] { return master.placeholderEntries ?? master.placeholders.map((node) => ({ node })); } @@ -523,7 +494,7 @@ function getPhBodyPr(phNode: SafeXmlNode): SafeXmlNode | undefined { function inheritPlaceholderType(target: PlaceholderInfo, sourceNode: SafeXmlNode): void { if (target.type) return; - const source = getPhInfo(sourceNode); + const source = getPlaceholderInfo(sourceNode); if (source.type) { target.type = source.type; } @@ -573,17 +544,18 @@ export function resolveNodePlaceholderInheritance( ): void { if (!node.placeholder) return; - const { idx } = node.placeholder; - const layoutMatch = layout ? findMatchingLayoutPlaceholder(layout.placeholders, idx) : undefined; + const layoutMatch = layout + ? findMatchingLayoutPlaceholder(layout.placeholders, node.placeholder, (entry) => + getPlaceholderInfo(entry.node), + ) + : undefined; if (layoutMatch) inheritPlaceholderType(node.placeholder, layoutMatch.node); // Layout -> master uses type, never idx: master idx values may collide with unrelated types. const masterMatch = master - ? getMasterPlaceholderEntries(master).find( - (entry) => - (getPhInfo(entry.node).type ?? 'obj') === - masterPlaceholderType( - layoutMatch ? getPhInfo(layoutMatch.node).type : node.placeholder?.type, - ), + ? findMatchingMasterPlaceholder( + getMasterPlaceholderEntries(master), + layoutMatch ? getPlaceholderInfo(layoutMatch.node).type : node.placeholder?.type, + (entry) => getPlaceholderInfo(entry.node), ) : undefined; diff --git a/src/model/placeholderMatching.ts b/src/model/placeholderMatching.ts index de21956..5b80798 100644 --- a/src/model/placeholderMatching.ts +++ b/src/model/placeholderMatching.ts @@ -1,3 +1,52 @@ +import { SafeXmlNode } from '../parser/XmlParser'; +import type { PlaceholderInfo } from './nodes/BaseNode'; + +export const UNLINKED_PLACEHOLDER_IDX = 0xffffffff; + +const PLACEHOLDER_WRAPPERS = [ + 'nvSpPr', + 'nvPicPr', + 'nvGrpSpPr', + 'nvGraphicFramePr', + 'nvCxnSpPr', +] as const; + +export function getPlaceholderInfo(node: SafeXmlNode): PlaceholderInfo { + for (const wrapper of PLACEHOLDER_WRAPPERS) { + const ph = node.child(wrapper).child('nvPr').child('ph'); + if (!ph.exists()) continue; + + const idx = ph.numAttr('idx'); + return { + type: ph.attr('type'), + idx: idx !== undefined && Number.isFinite(idx) ? idx : undefined, + }; + } + return {}; +} + +export function findMatchingLayoutPlaceholder( + placeholders: readonly T[], + target: PlaceholderInfo, + getInfo: (placeholder: T) => PlaceholderInfo, +): T | undefined { + if (target.idx !== undefined && target.idx !== UNLINKED_PLACEHOLDER_IDX) { + return placeholders.find((placeholder) => (getInfo(placeholder).idx ?? 0) === target.idx); + } + + const targetType = target.type ?? 'obj'; + return placeholders.find((placeholder) => (getInfo(placeholder).type ?? 'obj') === targetType); +} + +export function findMatchingMasterPlaceholder( + placeholders: readonly T[], + layoutType: string | undefined, + getInfo: (placeholder: T) => PlaceholderInfo, +): T | undefined { + const targetType = masterPlaceholderType(layoutType); + return placeholders.find((placeholder) => (getInfo(placeholder).type ?? 'obj') === targetType); +} + /** * Layout placeholder categories that inherit from the master's body/title placeholder. * Master inheritance uses this type mapping, not the layout/slide idx namespace. diff --git a/src/renderer/TextRenderer.ts b/src/renderer/TextRenderer.ts index 126f15f..4b59b8d 100644 --- a/src/renderer/TextRenderer.ts +++ b/src/renderer/TextRenderer.ts @@ -3,7 +3,12 @@ * with full 7-level style inheritance. */ -import { masterPlaceholderType } from '../model/placeholderMatching'; +import { + findMatchingLayoutPlaceholder, + findMatchingMasterPlaceholder, + getPlaceholderInfo, + masterPlaceholderType, +} from '../model/placeholderMatching'; import { SafeXmlNode } from '../parser/XmlParser'; import { RenderContext } from './RenderContext'; import type { TextBody, TextParagraph, TextRun } from '../model/nodes/ShapeNode'; @@ -110,38 +115,6 @@ function getPlaceholderCategory( return 'other'; } -/** - * Find a placeholder node in a list by matching type and/or idx. - */ -function findPlaceholderNode( - placeholders: SafeXmlNode[], - info: PlaceholderInfo, - matchBy: 'idx' | 'type', -): SafeXmlNode | undefined { - for (const ph of placeholders) { - // Navigate to the ph element to read its attributes - let phEl: SafeXmlNode | undefined; - const nvSpPr = ph.child('nvSpPr'); - if (nvSpPr.exists()) { - phEl = nvSpPr.child('nvPr').child('ph'); - } - if (!phEl || !phEl.exists()) { - const nvPicPr = ph.child('nvPicPr'); - if (nvPicPr.exists()) { - phEl = nvPicPr.child('nvPr').child('ph'); - } - } - if (!phEl || !phEl.exists()) continue; - - const phType = phEl.attr('type'); - const phIdx = phEl.numAttr('idx'); - - if (matchBy === 'idx' && (phIdx ?? 0) === (info.idx ?? 0)) return ph; - if (matchBy === 'type' && (phType ?? 'obj') === masterPlaceholderType(info.type)) return ph; - } - return undefined; -} - /** * Extract lstStyle from a placeholder shape node. */ @@ -1222,16 +1195,13 @@ export function renderTextBody( ): void { const textBody = resolveTextFields(sourceTextBody, ctx); const layoutPh = placeholder - ? findPlaceholderNode( + ? findMatchingLayoutPlaceholder( ctx.layout.placeholders.map((entry) => entry.node), placeholder, - 'idx', + getPlaceholderInfo, ) : undefined; - const layoutPhProps = layoutPh?.child('nvSpPr').child('nvPr').child('ph'); - const layoutPhType = layoutPhProps?.exists() - ? layoutPhProps.attr('type') - : layoutPh?.child('nvPicPr').child('nvPr').child('ph').attr('type'); + const layoutPhType = layoutPh ? getPlaceholderInfo(layoutPh).type : undefined; // Follow the parent's parent: a local slide type does not rewire layout -> master. const masterInfo = layoutPh ? { type: layoutPhType } : placeholder; const category = getPlaceholderCategory( @@ -1309,7 +1279,11 @@ export function renderTextBody( // Level 4: master placeholder lstStyle if (masterInfo) { - const masterPh = findPlaceholderNode(ctx.master.placeholders, masterInfo, 'type'); + const masterPh = findMatchingMasterPlaceholder( + ctx.master.placeholders, + masterInfo.type, + getPlaceholderInfo, + ); if (masterPh) { const lstStyle = getPlaceholderLstStyle(masterPh); mergeParagraphProps(merged, findStyleAtLevel(lstStyle, level)); diff --git a/test/e2e/oracle/README.md b/test/e2e/oracle/README.md index 0a464cb..f24649a 100644 --- a/test/e2e/oracle/README.md +++ b/test/e2e/oracle/README.md @@ -237,17 +237,18 @@ Report (default): ## Python-pptx Ground Truth Pipeline A second pipeline uses `python-pptx` for PPTX creation and native PowerPoint automation for -ground-truth export. It defines 204 cases under `oracle/cases-pypptx/` with the +ground-truth export. It defines 205 cases under `oracle/cases-pypptx/` with the `oracle-pypptx-*` prefix: -- **Text** (62 cases): fonts, sizes, styles, alignment, colors, bullets, vertical text, +- **Text** (63 cases): fonts, sizes, styles, alignment, colors, bullets, vertical text, placeholder inheritance, plus a 16-case CJK wrap/autofit/line-spacing interaction matrix whose final four cases cover square/wide/tall `spAutoFit` growth and explicit-overflow opt-out, and a four-case `defRPr`/`fontRef` color-precedence matrix with explicit-run and inverse controls. The - final three cases isolate styled soft breaks, an eleven-slide native tab-stop matrix, and a + final four cases isolate styled soft breaks, an eleven-slide native tab-stop matrix, a nine-slide matrix for every non-horizontal DrawingML vertical mode, anchors, stacked-character - advance, and mixed CJK/Hangul fallback. Explicit left/center/right/decimal tabs and the vertical - tab row are verified; the RTL tab row remains an observation control + advance, and mixed CJK/Hangul fallback, plus duplicate unlinked placeholder indices. Explicit + left/center/right/decimal tabs and the vertical tab row are verified; the RTL tab row remains an + observation control - **Shape adjustments** (31 cases): adjustment handles for roundRect, chevron, arrow, star, donut, cross, trapezoid, blockArc, bevel, triangle, pentagon, can, heart, moon, brace - **Zero-adjustment flowcharts** (28 cases, 84 slides): presets in shape IDs 61-88, each with square explicit paint, wide theme-reference paint, and grouped-tall rendering @@ -297,7 +298,7 @@ Generate cases: cd test/e2e .venv/bin/python3 scripts/generate_pypptx_cases.py -# Focus one or more exact/glob patterns; this example selects text IDs 0040-0062. +# Focus one or more exact/glob patterns; this example selects text IDs 0040-0063. .venv/bin/python3 scripts/generate_pypptx_cases.py \ --case 'oracle-pypptx-text-00[456]*' diff --git a/test/e2e/oracle/cases-pypptx/oracle-pypptx-text-0063-unlinked-placeholder-idx-collision.json b/test/e2e/oracle/cases-pypptx/oracle-pypptx-text-0063-unlinked-placeholder-idx-collision.json new file mode 100644 index 0000000..829d87f --- /dev/null +++ b/test/e2e/oracle/cases-pypptx/oracle-pypptx-text-0063-unlinked-placeholder-idx-collision.json @@ -0,0 +1,25 @@ +{ + "name": "oracle-pypptx-text-0063-unlinked-placeholder-idx-collision", + "generator": "python-pptx", + "slides": [ + { + "nodes": [ + { + "kind": "pypptx-generated" + } + ] + } + ], + "coverage": { + "oracle": "native-powerpoint", + "requiredFonts": [ + "Arial" + ], + "features": [ + "placeholder.idx=4294967295", + "placeholder.layout-collision", + "placeholder.layout-match=type", + "text.inheritance.master-body" + ] + } +} diff --git a/test/e2e/scripts/generate_pypptx_cases.py b/test/e2e/scripts/generate_pypptx_cases.py index 6220fdd..96b05f6 100644 --- a/test/e2e/scripts/generate_pypptx_cases.py +++ b/test/e2e/scripts/generate_pypptx_cases.py @@ -399,6 +399,85 @@ def _patch_placeholder_idx_inheritance_case(pptx_path: Path) -> None: _replace_pptx_entries(pptx_path, entries, patched) +def _patch_unlinked_placeholder_collision_case(pptx_path: Path) -> None: + """Create the max-idx layout collision reported in issue #26.""" + slide_part = "ppt/slides/slide1.xml" + + with ZipFile(pptx_path, "r") as zf: + entries, data_by_name = _read_pptx_entries(pptx_path) + layout_part = _relationship_target(zf, slide_part, "/slideLayout") + if layout_part is None: + raise RuntimeError("unlinked placeholder case slide has no slideLayout relationship") + + slide_root = etree.fromstring(data_by_name[slide_part]) + layout_root = etree.fromstring(data_by_name[layout_part]) + + slide_body_shapes = slide_root.xpath( + ".//p:sp[p:nvSpPr/p:nvPr/p:ph[@idx='1']]", namespaces=NS + ) + layout_body_shapes = layout_root.xpath( + ".//p:sp[p:nvSpPr/p:nvPr/p:ph[@idx='1']]", namespaces=NS + ) + if not slide_body_shapes or not layout_body_shapes: + raise RuntimeError("unlinked placeholder case has no body placeholder") + + slide_body = slide_body_shapes[0] + slide_ph = slide_body.find("./p:nvSpPr/p:nvPr/p:ph", namespaces=NS) + slide_ph.set("type", "body") + slide_ph.set("idx", "4294967295") + + slide_sp_pr = slide_body.find(qn("p:spPr")) + for current in slide_sp_pr.findall(qn("a:xfrm")): + slide_sp_pr.remove(current) + slide_xfrm = etree.Element(qn("a:xfrm")) + etree.SubElement( + slide_xfrm, + qn("a:off"), + x=str(_emu(6.8)), + y=str(_emu(1.5)), + ) + etree.SubElement( + slide_xfrm, + qn("a:ext"), + cx=str(_emu(5.7)), + cy=str(_emu(4.7)), + ) + slide_sp_pr.insert(0, slide_xfrm) + + layout_body = layout_body_shapes[0] + layout_body_ph = layout_body.find("./p:nvSpPr/p:nvPr/p:ph", namespaces=NS) + layout_body_ph.set("type", "body") + layout_body_ph.set("idx", "4294967295") + + panel_placeholder = etree.fromstring(etree.tostring(layout_body)) + panel_c_nv_pr = panel_placeholder.find("./p:nvSpPr/p:cNvPr", namespaces=NS) + panel_c_nv_pr.set("id", "99") + panel_c_nv_pr.set("name", "Unlinked panel placeholder") + panel_ph = panel_placeholder.find("./p:nvSpPr/p:nvPr/p:ph", namespaces=NS) + panel_ph.attrib.pop("type", None) + panel_ph.set("idx", "4294967295") + + panel_lst_style = panel_placeholder.find( + "./p:txBody/a:lstStyle", namespaces={**NS, "a": DRAWINGML_NS} + ) + if panel_lst_style is None: + raise RuntimeError("unlinked panel placeholder has no list style") + for child in list(panel_lst_style): + panel_lst_style.remove(child) + level = etree.SubElement(panel_lst_style, qn("a:lvl1pPr")) + default_run = etree.SubElement(level, qn("a:defRPr")) + solid_fill = etree.SubElement(default_run, qn("a:solidFill")) + etree.SubElement(solid_fill, qn("a:srgbClr"), val="FFFFFF") + + layout_body.getparent().insert(layout_body.getparent().index(layout_body), panel_placeholder) + + patched = { + slide_part: etree.tostring(slide_root, encoding="UTF-8", xml_declaration=True), + layout_part: etree.tostring(layout_root, encoding="UTF-8", xml_declaration=True), + } + _replace_pptx_entries(pptx_path, entries, patched) + + def _patch_connector_tail_arrows(pptx_path: Path) -> None: """Add OOXML tail arrowheads to generated connectors where python-pptx has no API.""" slide_part = "ppt/slides/slide1.xml" @@ -1558,6 +1637,49 @@ def _configure_vertical_probe( ) cases[-1]["slide_count"] = 9 + def _build_unlinked_placeholder_collision(prs): + slide = prs.slides.add_slide(prs.slide_layouts[1]) + slide.shapes.title.text = "Unlinked placeholder idx collision" + + body = slide.placeholders[1] + body.text_frame.clear() + paragraph = body.text_frame.paragraphs[0] + paragraph.text = "This body text must inherit the master body color and remain black." + paragraph.font.name = "Arial" + paragraph.font.size = Pt(24) + + panel = slide.shapes.add_shape( + MSO_SHAPE.RECTANGLE, + _emu(0.6), + _emu(1.5), + _emu(5.6), + _emu(4.7), + ) + panel.fill.solid() + panel.fill.fore_color.rgb = RGBColor(31, 41, 55) + panel.line.fill.background() + panel.text = "The first layout placeholder uses white text.\nIt must not style the body on the right." + for panel_paragraph in panel.text_frame.paragraphs: + panel_paragraph.font.name = "Arial" + panel_paragraph.font.size = Pt(22) + panel_paragraph.font.color.rgb = RGBColor(255, 255, 255) + + _add( + "unlinked-placeholder-idx-collision", + _build_unlinked_placeholder_collision, + _patch_unlinked_placeholder_collision_case, + coverage={ + "oracle": "native-powerpoint", + "requiredFonts": ["Arial"], + "features": [ + "placeholder.idx=4294967295", + "placeholder.layout-collision", + "placeholder.layout-match=type", + "text.inheritance.master-body", + ], + }, + ) + return cases diff --git a/test/e2e/test_pypptx_generator_cases.py b/test/e2e/test_pypptx_generator_cases.py index ba2ac3b..a64a593 100644 --- a/test/e2e/test_pypptx_generator_cases.py +++ b/test/e2e/test_pypptx_generator_cases.py @@ -80,11 +80,58 @@ def test_placeholder_idx_inheritance_case_generates_idx_only_slide_placeholders( assert {"type": "body", "idx": "1"} in master_placeholders +def test_unlinked_placeholder_collision_case_keeps_typed_layout_fallback(tmp_path: Path): + generator = _load_generator_module() + case_defs = generator._build_all_case_defs() + case = next( + c + for c in case_defs + if c["name"] + == "oracle-pypptx-text-0063-unlinked-placeholder-idx-collision" + ) + pptx_path = tmp_path / "source.pptx" + + generator._generate_pptx(case, pptx_path) + + with ZipFile(pptx_path) as zf: + slide_part = "ppt/slides/slide1.xml" + layout_part = generator._relationship_target(zf, slide_part, "/slideLayout") + assert layout_part is not None + slide_root = etree.fromstring(zf.read(slide_part)) + layout_root = etree.fromstring(zf.read(layout_part)) + + ns = { + "p": "http://schemas.openxmlformats.org/presentationml/2006/main", + "a": "http://schemas.openxmlformats.org/drawingml/2006/main", + } + slide_body = slide_root.xpath( + ".//p:ph[@type='body'][@idx='4294967295']", namespaces=ns + ) + colliding_layout_shapes = layout_root.xpath( + ".//p:sp[p:nvSpPr/p:nvPr/p:ph[@idx='4294967295']]", namespaces=ns + ) + + assert len(slide_body) == 1 + assert len(colliding_layout_shapes) == 2 + assert colliding_layout_shapes[0].xpath( + "./p:nvSpPr/p:nvPr/p:ph[not(@type)]", namespaces=ns + ) + assert colliding_layout_shapes[0].xpath( + ".//a:srgbClr[@val='FFFFFF']", namespaces=ns + ) + assert colliding_layout_shapes[1].xpath( + "./p:nvSpPr/p:nvPr/p:ph[@type='body']", namespaces=ns + ) + + def test_cjk_text_layout_matrix_is_registered(): generator = _load_generator_module() case_defs = generator._build_all_case_defs() text_names = [ - case["name"] for case in case_defs if case["name"].startswith("oracle-pypptx-text-") + case["name"] + for case in case_defs + if case["name"].startswith("oracle-pypptx-text-") + and not case["name"].startswith("oracle-pypptx-text-effect-") ] expected_names = { @@ -111,6 +158,7 @@ def test_cjk_text_layout_matrix_is_registered(): "oracle-pypptx-text-0060-styled-soft-break-matrix", "oracle-pypptx-text-0061-tab-stop-matrix", "oracle-pypptx-text-0062-vertical-mode-matrix", + "oracle-pypptx-text-0063-unlinked-placeholder-idx-collision", } assert expected_names.issubset(text_names) diff --git a/test/unit/model/Presentation.test.ts b/test/unit/model/Presentation.test.ts index 790f35e..256868c 100644 --- a/test/unit/model/Presentation.test.ts +++ b/test/unit/model/Presentation.test.ts @@ -815,6 +815,51 @@ describe('buildPresentation', () => { expect(node.size.w).toBeGreaterThan(0); }); + it('treats max unsigned idx as unlinked and matches the layout placeholder by type', () => { + const slideXml = ` + + + + + + +

Body text

+
+
+
+
+ `; + const layoutXml = ` + + + + + + +

Panel

+
+ + + +

Body

+
+
+
+
+ `; + const files = makeMinimalFiles({ + slides: new Map([['ppt/slides/slide1.xml', slideXml]]), + slideLayouts: new Map([['ppt/slideLayouts/slideLayout1.xml', layoutXml]]), + }); + + const pres = buildPresentation(files); + const node = pres.slides[0].nodes[0] as any; + + expect(node.position.x).toBeCloseTo(192, 0); + expect(node.position.y).toBeCloseTo(144, 0); + expect(node.textBody.layoutBodyProperties.attr('anchor')).toBe('ctr'); + }); + it('inherits placeholder type from layout when slide placeholder declares only idx', () => { const slideXml = ` diff --git a/test/unit/renderer/TextRenderer.test.ts b/test/unit/renderer/TextRenderer.test.ts index 2745a2c..77b9e47 100644 --- a/test/unit/renderer/TextRenderer.test.ts +++ b/test/unit/renderer/TextRenderer.test.ts @@ -1976,6 +1976,90 @@ describe('TextRenderer — renderTextBody', () => { expect(para.style.textAlign).toBe('center'); }); + it('matches a max-idx layout placeholder by type instead of the first idx collision', () => { + const body = makeTextBody({ + paragraphs: [{ runs: [{ text: 'Visible body text' }], level: 0 }], + }); + const ctx = createMockRenderContext(); + const panelPlaceholder = xmlNode(` + + + + +

Panel

+
+
+ `); + const bodyPlaceholder = xmlNode(` + + + + +

Body

+
+
+ `); + ctx.layout.placeholders = [{ node: panelPlaceholder }, { node: bodyPlaceholder }]; + + const container = document.createElement('div'); + renderTextBody(body, { type: 'body', idx: 4294967295 }, ctx, container); + + const paragraph = container.children[0] as HTMLElement; + const span = container.querySelector('span') as HTMLElement; + expect(paragraph.style.textAlign).toBe('center'); + expect(span.style.color).toBe('rgb(0, 0, 0)'); + }); + + it('matches a placeholder without idx by type', () => { + const body = makeTextBody({ + paragraphs: [{ runs: [{ text: 'Unindexed body text' }], level: 0 }], + }); + const ctx = createMockRenderContext(); + const objectPlaceholder = xmlNode(` + + +

Object

+
+ `); + const bodyPlaceholder = xmlNode(` + + +

Body

+
+ `); + ctx.layout.placeholders = [{ node: objectPlaceholder }, { node: bodyPlaceholder }]; + + const container = document.createElement('div'); + renderTextBody(body, { type: 'body' }, ctx, container); + + expect((container.children[0] as HTMLElement).style.textAlign).toBe('center'); + }); + + it('keeps an ordinary explicit idx authoritative over placeholder type', () => { + const body = makeTextBody({ + paragraphs: [{ runs: [{ text: 'Indexed text' }], level: 0 }], + }); + const ctx = createMockRenderContext(); + const sameTypePlaceholder = xmlNode(` + + +

Body

+
+ `); + const sameIdxPlaceholder = xmlNode(` + + +

Object

+
+ `); + ctx.layout.placeholders = [{ node: sameTypePlaceholder }, { node: sameIdxPlaceholder }]; + + const container = document.createElement('div'); + renderTextBody(body, { type: 'body', idx: 7 }, ctx, container); + + expect((container.children[0] as HTMLElement).style.textAlign).toBe('right'); + }); + it('layout placeholder lstStyle overrides master placeholder lstStyle', () => { const body = makeTextBody({ paragraphs: [ @@ -2518,9 +2602,7 @@ describe('TextRenderer — renderTextBody', () => { paragraphs: [ { runs: [{ text: '\t' }, { text: 'After' }], - properties: xmlNode( - '', - ), + properties: xmlNode(''), level: 0, }, ],