From d671e412aec29014b73e7309afcedafcad2084a9 Mon Sep 17 00:00:00 2001 From: commilitia Date: Tue, 13 Oct 2026 17:16:50 +0800 Subject: [PATCH] Release PPT workflow 0.7.3 with fixed spacing, paragraphs and native math --- README.md | 6 +- SKILL.md | 6 +- UPDATES.md | 20 ++ USAGE.md | 6 + references/charts.md | 4 +- references/components.md | 2 +- references/mathematics.md | 54 ++++ references/native-authoring.md | 6 +- references/native-objects.md | 6 +- references/page-library.md | 2 + references/powerpoint-checks.md | 4 +- references/production-rules.md | 8 +- references/runtime-and-provenance.md | 4 +- references/spacing.md | 25 ++ references/text-and-containers.md | 32 ++- references/visual-design.md | 2 + scripts/basic_component.py | 48 ++-- scripts/chart_component.py | 57 ++-- scripts/component_typography.py | 52 ++++ scripts/layout_spacing.py | 159 +++++++++++ scripts/math_component.py | 413 +++++++++++++++++++++++++++ scripts/native_export.py | 10 +- scripts/paragraph_layout.py | 192 +++++++++++++ scripts/prepare_native_project.py | 3 +- scripts/table_component.py | 33 ++- scripts/text_block.py | 134 +++++++++ 26 files changed, 1210 insertions(+), 78 deletions(-) create mode 100644 references/mathematics.md create mode 100644 references/spacing.md create mode 100644 scripts/component_typography.py create mode 100644 scripts/layout_spacing.py create mode 100644 scripts/math_component.py create mode 100644 scripts/paragraph_layout.py create mode 100644 scripts/text_block.py diff --git a/README.md b/README.md index aa5a9e4..91d29ab 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Templated-PPT-Master -可独立安装的 `ppt-template-workflow` Skill,当前源码版本 **0.7.2**。仓库根目录就是唯一安装单元:[SKILL.md](SKILL.md)。它提供模板选择、逐页设计、素材上色、原生 PPTX、图表与表格、页面协作和可重建工程;无需另装其他 Skill。template、theme、组织素材和字体独立提供。 +可独立安装的 `ppt-template-workflow` Skill,当前版本 **0.7.3**。仓库根目录就是唯一安装单元:[SKILL.md](SKILL.md)。它提供模板选择、逐页设计、素材上色、原生 PPTX、图表与表格、页面协作和可重建工程;无需另装其他 Skill。template、theme、组织素材和字体独立提供。 在支持本地文件和命令执行的 Agent 中输入: @@ -16,4 +16,8 @@ Agent 应读取本页和 [INSTALL.md](INSTALL.md),通过 Git 克隆这个地 0.7.2新增[文字与容器硬约束](references/text-and-containers.md):段落优先使用一个原生文本框,必要拆分保持逻辑统一;实质说明不放小号灰字;同容器不同文字角色不能只靠字体区分;同类容器共用内部排版实现,调用者不得逐例摆放内部文字和图标。 +0.7.3统一组件的文字角色解析,落实所选template的字体、字号、字重与行高;图表标题、标注和来源使用不同层级。新增C20通用段落硬约束:遵守标点避头尾,每一行均不得只有一个字或一个字加周围标点;以完整段落重排并检查实际渲染。图表轴暂只支持常规字重,无法兑现的字重明确拒绝。 + +0.7.3将[间距固定到template和共享recipe](references/spacing.md):调用者不传间距数字、阈值或临时变体。共享多段文本组件读取固定内边距与段间距,自动生成精确关系检查,过窄与过宽均拒绝;正文仍保留内容驱动的换行与占用高度。[数学](references/mathematics.md)以专用字体和结构化表达式生成Office Math;不依赖通用TeX服务或另一个Skill,不把线性兼容文字当成公式效果或编辑证据。 + 包内 `runtime/ppt-master/` 保留 280 个未修改的 MIT 上游文件,包括用于署名的嵌套 SKILL.md;它不是第二个可安装 Skill。[来源和校验](runtime/provenance.json)、[运行库与边界](references/runtime-and-provenance.md)、[版本变化](UPDATES.md)一并保留。 diff --git a/SKILL.md b/SKILL.md index 8ac3ac4..2e58ff0 100644 --- a/SKILL.md +++ b/SKILL.md @@ -2,7 +2,7 @@ name: ppt-template-workflow description: Create and revise template-based editable PPTX decks, design page mockups and materials, reconstruct visual pages into native objects, and maintain portable source projects. Select one external template and theme at a time; includes its own native compiler and workflows without requiring another installed Skill. metadata: - version: "0.7.2" + version: "0.7.3" --- # PPT Template Workflow @@ -15,6 +15,10 @@ Read [production rules](references/production-rules.md) for the whole workflow. For every authored or reconstructed page, apply the required [text and container rules](references/text-and-containers.md): prefer one native text frame per paragraph; any necessary fragments must remain one logical text block with unified content and layout. Keep substantive explanations out of small muted notes, distinguish different text roles by size/weight/color, and use one shared internal layout implementation per container type/variant. Authors supply content and whole-container bounds, never instance-specific child positions. +Paragraphs must also obey punctuation prohibitions at line starts/ends and must never leave a line with only one substantive character, alone or surrounded by punctuation. Check every rendered line, including the final line, and reflow the whole paragraph without breaking its logical identity. The shared [paragraph helper](scripts/paragraph_layout.py) supports measured plain-text wrapping; it does not replace actual render checks. + +Spacing is [fixed by the template and shared recipe](references/spacing.md) for each relationship, including paragraph gaps. Content callers supply content and whole-container placement, never numeric spacing, check thresholds or per-instance variants. Check fixed values, collisions and actual visual clearance. The [text-block and spacing helpers](references/text-and-containers.md#spacing-and-paragraph-separation) preserve these values through reflow. For mathematics, read [math authoring](references/mathematics.md): use the declared math font, preserve equation semantics and verify Office Math separately from ordinary text or pictures. + ## Select and review Read the supplied catalog or manifest with `scripts/template_ref.py`. For a foundation project, start with its README consumer links. If the task needs a template and no source is specified, ask once for the source instead of guessing a global library. diff --git a/UPDATES.md b/UPDATES.md index c50b510..835d836 100644 --- a/UPDATES.md +++ b/UPDATES.md @@ -1,5 +1,25 @@ # 版本变化 +## 0.7.3 + +将C21/C22进一步固定:spacing属于外部template与共享recipe,内容调用者不提供数字、阈值、偏移或临时变体。`resolve_spacing`按role路径读取唯一数值;`check_spacing(...,style=...)`只接受role,精确核对间隔与四侧padding,过小/过大均拒绝。多段文字自动记录固定padding和paragraph关系;空白可用区不要求文字填满。旧template可按既有固定字段读取,不猜新数字。 + +包含preview.1/preview.2的文字角色、C20段落、共享多段文本与Office Math能力。运行库仍为既有9项,上游280个原文件保持;新增模块随源码工程复制。数学字体在外部template/工程声明,实际应用渲染、公式编辑、投影和其他设备验收分别报告。 + +## 0.7.3-preview.2 + +新增C21—C23:检查内容净距与内边距,使用适当而不特别宽的间距;独立控制段间距,保留语义段落;数学使用显式声明的专用字体并核对公式对象类型。`text_block.py`按完整段落数组、模板已有padding/paragraph_gap和文字角色共享排版,`layout_spacing.py`核对显式方向间隔与四侧内边距;不猜所有对象的语义关系,也不以几何无重叠替代视觉通过。 + +`math_component.py`新增结构化表达式到Office Math接口:分式、根式、上下标、定界、求和/积分及矩阵,原生包装器保留数学字体、字号与颜色。上游280文件保持原样,依赖仍为既有9项。源SVG与兼容分支是线性文字,不是复杂公式效果图;尚非通用LaTeX或段内混排解析器。可移动工程准备器同步包含数学、文字角色、段落和间距模块。字体与应用验收单独声明。 + +## 0.7.3-preview.1 + +本地候选,尚未发布。统一基本组件、分组表格与图表的文字角色解析,分别读取角色对应的字体、字号、字重和行高,避免只换字号而继续使用同一个字体。指标的数值与单位保留各自角色;RUC当前默认基本组件和表格几何不变。 + +按用户明确要求新增C20通用排版硬约束:段落遵守标点避头尾,任何一行(含末行)不得出现单字、单字加标点或标点包围单字成行。通过共享段落实现重排全部内容,不拆成独立行文本框,也不删字、添字、缩字或手动移动标点。新增可由实际字体测量驱动的纯文本换行与逐行检查助手;实际应用的再次换行仍需核对。 + +图表标题使用label与accent2,单位/轴/图例保留显式图表文字配置,来源使用辅助颜色dk2;按角色行高预留文字空间,容量不足时报错。原生Chart轴只支持常规字重,要求其他字重时拒绝;无轴的pie/doughnut独立文字可保留加粗。页面库文档明确设计参考可用性与共享排版实现是不同证据。 + ## 0.7.2 增加C16—C19文字与容器硬约束:段落优先单个原生文本框,必要拆分保持完整内容、整体边界和统一排版的逻辑文本块;小号灰字不得承载实质解释、关键条件/时机、结论或影响解释的限定;不同文字角色通过字号、字重和/或颜色区分;同类容器共用内部排版实现,禁止逐例手动摆放文字和图标。补齐SVG到PPTX的段落结构检查、组件使用、重建和按页协作的执行说明;不将导出成功视为语义或应用验收。 diff --git a/USAGE.md b/USAGE.md index e5291be..1d41db4 100644 --- a/USAGE.md +++ b/USAGE.md @@ -39,6 +39,12 @@ Agent 读取导出的调色板、字体、网格、素材 paint 契约、结构 制作与重建均遵循[文字与容器硬约束](references/text-and-containers.md)。段落优先一个原生文本框;确需拆分时共用完整文本、整体边界、阅读顺序和排版实现,不逐行维护。关键解释、流程条件/时机、结论与影响解释的限定不得藏在小号灰字。容器中的不同文字角色需用字号、字重和/或颜色区分;同类容器由共享实现计算内部位置,作者只填内容、选择已声明变体并放置整体容器。 +段落还须遵守标点避头尾;检查每一行,包括末行,禁止单字或被标点包围的单字独占一行。重新排整段文字来消除问题,保持完整内容、逻辑文本块和声明字号。纯文本可使用`paragraph_layout.py`配合实际字体测量;复杂文字与PowerPoint重新换行的结果仍按实际画面检查。 + +间距由template与共享recipe固定,调用者不传数字、阈值、偏移或临时变体。检查文字与边框、标题与正文、相邻内容和公式周围的固定关系,过窄与过宽均拒绝。多段文字用完整段落数组,段内行距与段间距分别控制;`text_block.py`读取固定padding与paragraph_gap并自动记录关系,`layout_spacing.py`按role精确核对。几何检查不能替代实际字形与画面审阅,见[固定间距](references/spacing.md)。 + +含数学内容时读取[数学制作](references/mathematics.md),声明专用数学字体。`math_component.py`与原生导出包装器支持结构化表达式的Office Math输出;字体、字号角色和颜色由template或显式工程样式提供。它不是通用LaTeX解析器;源SVG的线性兼容文字也不等于复杂公式预览。目标应用的渲染和实际公式编辑分别验证。 + ```sh "$PPT_PYTHON" "$PPT_SKILL/scripts/native_export.py" \ --manifest /path/to/template/manifest.json --theme THEME \ diff --git a/references/charts.md b/references/charts.md index a91cda0..f4efbcc 100644 --- a/references/charts.md +++ b/references/charts.md @@ -30,7 +30,9 @@ JSON顶层字段:`schema_version: 1`,`data_kind`(`synthetic-demo`/`obser ## 样式来源 -`style`和`theme`分别使用`template_ref.py export-style`的`style`、`theme_data`。字体从`typography.families.body`取得,字号默认取`caption`角色,缺少时取`label`;按`canvas.pt_to_px`换算。组件没有组织、字体名或十六进制配色的固定默认值。 +`style`和`theme`分别使用`template_ref.py export-style`的`style`、`theme_data`。每个文字角色通过`family_role`解析自己的字体、字号、字重和行高,按`canvas.pt_to_px`换算;不会把所有角色强制映射到body字体。图表标题使用`label`角色与`accent2`;没有`label`的旧样式沿用图表文字角色,通过颜色区分。单位、轴和图例使用显式`components.charts.text_role`(默认`caption`,缺少时为`label`)及`text_palette_index`;来源沿用标注角色的字体和字号,但颜色为`dk2`。组件没有组织、字体名或十六进制配色的固定默认值。 + +原生Chart轴当前只支持Regular(400),对需要其他轴字重的角色明确报错,不只改变SVG预览。pie/doughnut没有轴,独立标题、图例和来源可按角色保留加粗。普通原生文字/Table使用常规与加粗的字重语义;中间字重仍需目标字体与应用验证,不能仅据SVG的数值声明推断精确字重。 template可选择声明`style.components.charts`: diff --git a/references/components.md b/references/components.md index c0bdbad..24fffb9 100644 --- a/references/components.md +++ b/references/components.md @@ -12,7 +12,7 @@ Each JSON uses `schema_version: 1` and `kind`. Examples are marked `data_kind: synthetic-demo`; preserve this declaration and source metadata when demonstrating. Real reports retain observations, definitions, missing-value policy and sources under [production rules](production-rules.md). Metadata remains in SVG; save original JSON with the page. The table payload is canonical native metadata, with original data in `data-component-source`; other groups carry original JSON in metadata. Native PPTX does not retain arbitrary SVG metadata, so retain project sources separately. -The table uses `style.components.table`, including theme-indexed header colors and padding. Text uses `typography.roles.label`, `caption` and `number`, their declared font families and `canvas.pt_to_px`. Other component colors use `dk1`, `dk2`, `accent1`, `line` and `lt1` from the palette. Internal spacing uses fixed 8px modules; derived equal columns/centers do not introduce new page alignment guides. Width checks are conservative estimates, not a font renderer. The tool rejects excess capacity instead of shrinking text. Rephrase, enlarge a slot, change layout or split content; inspect actual application output. +The table uses `style.components.table`, including theme-indexed header colors and padding. Text uses `typography.roles.label`, `caption` and `number`, each role's declared family, size, weight and line height, and `canvas.pt_to_px`. A table's explicit `text_role` takes precedence. Number and unit runs resolve their roles independently; caption roles do not inherit the label's family or weight. SVG and native Table payloads carry the same role choices. Native regular/bold semantics do not by themselves verify intermediate font weights in the destination application. Other component colors use `dk1`, `dk2`, `accent1`, `line` and `lt1` from the palette. Internal spacing uses fixed 8px modules; derived equal columns/centers do not introduce new page alignment guides. Width checks are conservative estimates, not a font renderer. The tool rejects excess capacity instead of shrinking text. Rephrase, enlarge a slot, change layout or split content; inspect actual application output. Apply the required [text and container rules](text-and-containers.md). Callers provide data, declared style/variant and whole-component bounds; do not move generated child text/icons or add per-instance internal offsets. Custom containers need a shared renderer too. Distinct functions such as heading/body must differ perceptibly in size, weight and/or palette color, not just family. The `detail`/`caption` name does not authorize essential conditions, conclusions or limitations to become small muted notes. Choose a suitable main-text component or extend the shared renderer when the existing field/role cannot carry the content appropriately. diff --git a/references/mathematics.md b/references/mathematics.md new file mode 100644 index 0000000..953f32e --- /dev/null +++ b/references/mathematics.md @@ -0,0 +1,54 @@ +# Mathematical content + +Use the math family declared by the selected template or an explicit project-local math-style input. Keep that declaration with the project; do not silently substitute the prose font or a different math family. An OpenType math font supplies mathematical glyphs and layout tables; its name alone neither installs it nor proves application rendering. Keep ordinary Chinese/English explanations in their prose role. Variables, operators, upright function names, units and grouping must retain the author's meaning. + +## Supported native route + +`scripts/math_component.py` creates structured expressions and the maintained `native_export.py` wrapper emits Office Math. The preserved upstream compiler remains unchanged. The wrapper uses its own `data-native-math="1"` group, not the upstream `data-pptx-replace-with` table/chart kinds. + +Supported expression nodes: + +| Input | Native structure | +|---|---| +| A nonempty string, or `text` with explicit plain/italic/bold/bold-italic style | `m:r` runs | +| `row` with ordered `items` | Ordered math expressions | +| `fraction` with `numerator` and `denominator` | `m:f` | +| `script` with `base` and `sub` and/or `sup` | `m:sSub`, `m:sSup`, `m:sSubSup` | +| `radical` with `body` and optional `degree` | `m:rad` | +| `delimiter` with `body`, `open`, `close` | `m:d` | +| `nary` with explicit operator, `body` and optional `lower`/`upper` | `m:nary` | +| Rectangular `matrix` with `rows` | `m:m` | + +The initial operator set is sum, product, integral, union and intersection. Unknown node types/fields, malformed matrices, empty expressions, unsupported transforms and excessive nesting are rejected. This is an explicit expression interface, **not a general LaTeX/MathML parser**. Do not strip unknown commands or flatten unsupported expressions to make a conversion appear successful. + +Use `render_math(data, style, theme, bounds, component_id, math_style=None)`: + +```python +data = { + "schema_version": 1, + "display": "block", + "expression": { + "type": "fraction", "numerator": "a+b", "denominator": "c" + }, +} +marker = render_math(data, style, theme, bounds, "equation-1", math_style) +page.append(marker) +``` + +`math_style` is an explicit external dictionary with `font_family`, a declared `text_role` and a theme `palette_index`. It can extend a project whose template does not yet have math roles. Without it, the template must declare `typography.families.math` and `math_display`/`math_inline` roles. The selected role supplies the actual point size; no arbitrary font-size field is accepted in component data. Keep the style declaration and generator in build inputs, and declare the math font in the destination environment. A project-local declaration does not rewrite a frozen template or certify a new template release. + +`display: "block"` creates `a14:m > m:oMathPara > m:oMath`. `display: "inline"` creates `a14:m > m:oMath` in a standalone native equation frame; this first API does not yet mix prose and equations inside one flowing text frame. Use the ordinary logical paragraph policy for surrounding explanations and do not claim inline paragraph reflow from separately positioned objects. + +The SVG contains complete JSON math metadata and a derived **linear text compatibility view**. Complex equations are not visually typeset by that SVG fallback. `native_export.py` validates it, compiles a temporary unique token, then replaces that token with native math at the declared bounds. No temporary token remains in the PPTX. Equations cannot be inside transformed source groups; regenerate their bounds instead. Math markers currently belong to authored pages, not Master/Layout definitions. + +The resulting `mc:AlternateContent` has an Office Math choice and an ordinary linear-text fallback for applications without math support. The fallback preserves expression meaning but is not an editable equation or a faithful complex-equation preview. Inspect the supported choice in the target application. No online equation service, additional installed Skill, font download or global installation is invoked. + +## Verification + +- Check complete input expressions against OMML: correct fraction operands, script attachment, radical degree, delimiter grouping, limits and matrix dimensions. Ordinary Unicode text alone is not proof of an equation. +- Check the declared family, actual destination font identity and OpenType MATH table. Verify glyph coverage and actual use in the application/export; do not silently replace a missing math font. Fonts are referenced, not distributed or embedded by this wrapper. +- Inspect mathematical runs and control properties for the selected family, size and color. Confirm Office Math choice nodes are present, no WordprocessingML-only properties or temporary tokens remain, and Master/Layout/notes are preserved. +- Render the current native file in a math-capable application. Check symbols, fraction bars, radicals, scripts, operators/limits, equation bounds and clearance from neighboring content. An SVG compatibility view or prior unrelated render cannot substitute. +- Actual equation editing is a separate test: select and change a representative expression in a disposable copy, save, then confirm the resulting equation structure/content. Native XML establishes structure only. + +Technical basis: Microsoft's [DrawingML math extension](https://learn.microsoft.com/en-us/openspecs/office_standards/ms-odrawxml/853b19c7-68a9-4f9a-a2ae-5e6cb0d02e62) and [native math example](https://learn.microsoft.com/en-us/openspecs/office_standards/ms-odrawxml/38b13e1f-1102-4bb7-819a-dd5d9abdb176). Font information: the [STIX project](https://www.stixfonts.org/) and its [official source](https://github.com/stipub/stixfonts). diff --git a/references/native-authoring.md b/references/native-authoring.md index 461773b..813838c 100644 --- a/references/native-authoring.md +++ b/references/native-authoring.md @@ -8,6 +8,10 @@ Use the template's discrete typography, grid, fixed content region and spacing. Apply [text and container rules](text-and-containers.md) before authoring: prefer one native frame per paragraph, use a unified logical block for any necessary fragments, distinguish text functions by size/weight/color and instantiate repeated containers through a shared renderer. Inspect actual native paragraph structure after export; positional SVG spans can otherwise be split by the compiler. +Before emitting paragraph rows, check punctuation at both line edges and prohibit every singleton row, including one character surrounded by punctuation. Reflow the complete paragraph with measured text widths; keep derived rows inside the same native frame. Repeat the check against actual rendered lines if PowerPoint performs further wrapping. + +Keep separate paragraphs separated by the template's fixed paragraph gap, retaining their normal internal line height. Spacing values and applicable relationships belong to the shared recipe; content callers cannot adjust them. Check exact fixed padding/gaps and actual glyph/edge clearance at normal scale. Use [text-block/spacing helpers](text-and-containers.md#spacing-and-paragraph-separation) for supported content. Explicit math markers use the [Office Math wrapper](mathematics.md), not the upstream table/chart marker kinds. + ## Current-page export The installer provides Python 3.12+ and [runtime requirements](../runtime/requirements.txt). Metadata-only selection and project commands use Python 3.10+ without third-party packages. @@ -35,4 +39,4 @@ Keep proposed styles and mockups outside the final template workspace until the The bundled runtime also carries the upstream preview compiler at `runtime/ppt-master/scripts/template_preview_pptx.py` for author review workspaces. It is source tooling, not a second Skill. Read its command help for a new workspace. Its platform font mapping is general-purpose: a template review wrapper should import the compiler and use `scripts/finalize_theme.py`'s `preserve_declared_font_families(style)` context around the serial compile, then `finalize(...)`. This preserves the declared family before information can be lost to platform fallback. Do not globally replace a fallback font in finished XML: the author may have intentionally used that same font. -For native tables/charts and formula limitations, read [specialized objects](native-objects.md) and its bundled minimal SVG examples. For placeholder metadata, inspect the corresponding bundled compiler module and local CLI help. Do not claim specialized objects from a screenshot or from a successful ZIP export alone. Finish with [render and application checks](powerpoint-checks.md). +For native tables/charts and equations, read [specialized objects](native-objects.md) and its examples. For placeholder metadata, inspect the corresponding bundled compiler module and local CLI help. Do not claim specialized objects from a screenshot or from a successful ZIP export alone. Finish with [render and application checks](powerpoint-checks.md). diff --git a/references/native-objects.md b/references/native-objects.md index ee9316e..5ca4616 100644 --- a/references/native-objects.md +++ b/references/native-objects.md @@ -26,8 +26,8 @@ Implementation entry points: - [chart data normalization](../runtime/ppt-master/scripts/svg_to_pptx/native_objects/chart_data.py) and [chart style](../runtime/ppt-master/scripts/svg_to_pptx/native_objects/chart_style.py). - [fallback freshness](../runtime/ppt-master/scripts/svg_to_pptx/native_objects/fallback_hash.py) and [validation/conversion](../runtime/ppt-master/scripts/svg_to_pptx/native_objects/__init__.py). -## Formula boundary +## Native mathematics and formula boundary -This maintained native SVG route has no authoring interface that creates Office Math equations: its native replacement kinds are table and chart. Simple mathematical Unicode may remain editable ordinary text, but that is not an editable equation object. Raster formulas and vector outlines must be disclosed as pictures/paths. The bundled optional LaTeX rendering utility produces PNG and can use external services; it is not a mandatory self-contained compiler capability. Do not invoke a network provider implicitly. +The preserved upstream snapshot's replacement kinds remain table and chart. The maintained wrapper adds a separate structured [Office Math authoring interface](mathematics.md) in `math_component.py`, with explicit font/style inputs. It supports common expression trees without changing upstream files or requiring another installed Skill. The source SVG contains a linear compatibility view; native equation rendering is checked in the target application. -If truly editable equations are required, agree an additional host or project-local authoring route and verify its output before promising that result. Input OMML extraction does not establish native equation output support. +Simple mathematical Unicode may remain editable ordinary text, but that is not an editable equation object. Raster formulas and vector outlines remain picture/path exceptions. Unsupported expressions require a separately declared compatible route; do not silently flatten them. The bundled optional LaTeX utility produces PNG and can use external services; it is not the maintained native equation route and must not be invoked as an implicit fallback. Input OMML extraction alone does not establish equation output or application editing support. diff --git a/references/page-library.md b/references/page-library.md index 54edbda..a3186f7 100644 --- a/references/page-library.md +++ b/references/page-library.md @@ -12,6 +12,8 @@ A template may declare `page_library: "page-library/catalog.json"`. This optiona The tool checks file SHA-256 and SVG href closure. It does not execute entry code. The native compiler still performs its own source/resource checks. A method may refer to a template-local generator; read it and invoke it only within the authorized project and host scope. +`eligible: true` means that the selected design reference has valid version/theme/file bindings. It does not prove a reusable content interface or compliance with the current [text and container rules](text-and-containers.md). If a historical example contains repeated materialized SVG groups but no shared generator, first supply one shared recipe and complete content inputs in the report project. Generate every repeated instance through it; do not turn a reference's per-line coordinates into author-maintained content. Retain the historical preview as a visual reference and validate the regenerated page separately. + ## Catalog contract The catalog has `schema_version: 1`, matching `template_id`, and `entries`. Each entry contains: diff --git a/references/powerpoint-checks.md b/references/powerpoint-checks.md index 5e55ce2..91a06b6 100644 --- a/references/powerpoint-checks.md +++ b/references/powerpoint-checks.md @@ -6,7 +6,9 @@ Open the current generated file, verify the visible filename and page thumbnails For rendered evidence, export this file to PDF through PowerPoint's own export dialog using the intended quality setting and a task-owned destination. Check the export exists and belongs to the current file, render its pages and inspect the images. Compare page geometry, text wrapping, typography, images, transparent edges, alignment and separators. Confirm actual PDF font identities when font rendering is disputed; a declared PowerPoint face alone does not prove which font the application rendered. -Use the four-part [text/container review](text-and-containers.md#review-before-delivery): paragraph frame or justified logical-block fallback, no substantive statements in small muted notes, visible size/weight/color hierarchy within containers, and one shared internal layout implementation per type/variant. Review content/source and native objects as well as the render. A screenshot cannot prove a shared renderer or unified text source; grouping cannot prove native paragraph flow. Test fragment reflow in the application only when that editing behavior is part of the requested evidence. +Use the [text/container review](text-and-containers.md#review-before-delivery): paragraph frame or justified logical-block fallback, no substantive statements in small muted notes, visible size/weight/color hierarchy within containers, one shared internal layout implementation per type/variant, compliant punctuation/singleton handling on every paragraph line, exact template-owned spacing and visible fixed paragraph gaps. Reject caller spacing overrides. Check actual glyphs/edges and visual rows, not only boxes, authored breaks or XML paragraphs. Review content/source and native objects as well as the render. A screenshot cannot prove a shared renderer or unified text source; grouping cannot prove native paragraph flow. Test fragment reflow in the application only when that editing behavior is part of the requested evidence. + +For equations, inspect `mc:Choice/a14:m` and OMML structures separately from compatibility fallback text. Verify the declared math family on runs and operator/control properties, missing glyphs, fraction/radical/script/limit placement and clearance from adjacent prose. Actual equation editing requires selecting and changing the equation in a disposable copy; XML or a correctly shaped picture cannot establish that evidence. See [math checks](mathematics.md#verification). Opening and exporting are distinct from actual editing. When editing evidence is requested, use a disposable task-owned copy: select a representative text/shape/placeholder, change it through the UI, save or export, and verify the resulting independent object/content change. Check an appropriate inherited object separately if template behavior is in scope. Do not alter the authoritative source project or a user's finished original merely to demonstrate editing. diff --git a/references/production-rules.md b/references/production-rules.md index 7869aa4..c2ac762 100644 --- a/references/production-rules.md +++ b/references/production-rules.md @@ -1,6 +1,6 @@ # Production rules -These rules apply across templates and authoring routes. **Required** rules protect content, provenance, implementation claims, rebuildable inputs and the explicit text/container constraints below. **Recommended** methods can change for a content reason; record the reason and relevant validation. Template parameters remain external. Follow explicit user instructions and settled authorization; these rules add no approval gate. C16–C19 are requirements, including where C04–C06 offer broader design recommendations. +These rules apply across templates and authoring routes. **Required** rules protect content, provenance, implementation claims, rebuildable inputs and the explicit text/container constraints below. **Recommended** methods can change for a content reason; record the reason and relevant validation. Template parameters remain external. Follow explicit user instructions and settled authorization; these rules add no approval gate. C16–C23 are requirements, including where C04–C06 offer broader design recommendations. ## Understand and compose @@ -35,5 +35,9 @@ These rules apply across templates and authoring routes. **Required** rules prot - **C17 · Required:** small muted notes may supply nonessential background, sources or definitions. They must not carry substantive explanations of a chart/visualization, key process conditions or timing, conclusions, or qualifications/disclaimers that affect interpretation. Put those statements in the main information hierarchy; do not hide them in notes or metadata instead. Classify by meaning, not by a field named `detail`, `caption` or `source`. - **C18 · Required:** different text functions inside one container, such as subheading and body, must have a perceptible distinction in declared size, weight and/or palette color. A font-family change alone is insufficient. Bind functions to named text roles and apply the same distinction to all instances. - **C19 · Required:** every instance of a container type/variant uses the same shared layout implementation for padding, slots, text roles, wrapping, icon placement and alignment. Authors provide content and whole-container placement/bounds; the implementation computes internal geometry. Do not hand-position/nudge text or icons per instance. Resolve capacity through the shared layout's content-driven rules, a meaningful shared variant, rewriting or page/layout changes, never a one-off override. See [implementation and review](text-and-containers.md). +- **C20 · Required:** paragraph lines obey punctuation rules for line starts and ends. Do not leave a line containing only one substantive character, whether alone, followed by punctuation, or surrounded by punctuation; this applies to every line, including the final line. Keep closing punctuation with preceding content and opening punctuation with following content; preserve unbreakable marks and words. Reflow the complete logical paragraph under its shared layout, then check the actual rendered lines. Do not fix this by deleting/changing content, splitting independent text boxes, or introducing undeclared font sizes, character spacing or per-line offsets. See [paragraph line breaking](text-and-containers.md#paragraph-line-breaking-rules). +- **C21 · Required:** spacing is fixed by the template and shared component recipe for each relationship: text-to-container padding, heading-to-body distance, adjacent groups/columns and clearance around connectors, rules and formulas. Content callers cannot supply numeric gaps, thresholds, per-instance spacing overrides or invent variants. Check the declared fixed values, including unintended excess spacing, as well as collisions and actual glyph/edge clearance. Preserve intentional decorative contacts under their own template rules. Capacity changes must reflow content or change the whole supported layout, not loosen/tighten its spacing. See [fixed spacing](spacing.md). +- **C22 · Required:** distinguish separate semantic paragraphs visually with the template's fixed paragraph spacing in addition to normal line spacing. Preserve paragraph boundaries in complete source content. The shared implementation owns this value; content callers cannot adjust it. Use native before/after paragraph spacing where the text-frame route supports it, or consistently positioned paragraph frames. Do not add empty paragraphs, repeated line breaks or per-instance offsets to simulate spacing. Reflowing a paragraph must preserve the fixed separation from the next paragraph. +- **C23 · Required:** mathematical content uses an explicitly declared mathematical font and preserves symbols, operators, scripts, fractions and grouping. Keep ordinary prose in its own text role. Use native Office Math when native equations are required; ordinary Unicode text, linear compatibility text and formula images are different object types. Verify the declared math font and the actual equation structure/rendering; do not silently substitute the prose font or claim equation editability from its appearance. See [mathematics](mathematics.md). -Technical helpers enforce supported inputs, path safety, object consistency and capacity. They do not infer paragraph identity, explanation importance or semantic text roles, and do not automatically enforce all four text/container rules. Inspect source and current native/rendered output; export success alone is insufficient. +Technical helpers enforce supported inputs, path safety, object consistency and capacity. They do not infer paragraph identity, explanation importance or semantic text roles, and do not automatically enforce every text/container rule across arbitrary source files. The paragraph helper checks the lines supplied to it and reflows supported plain text; it does not intercept arbitrary SVG or PowerPoint reflow. Inspect source and current native/rendered output; export success alone is insufficient. diff --git a/references/runtime-and-provenance.md b/references/runtime-and-provenance.md index 38398a5..056912b 100644 --- a/references/runtime-and-provenance.md +++ b/references/runtime-and-provenance.md @@ -8,11 +8,13 @@ The compiler is the MIT-licensed `ppt-master` 4.6.0 snapshot from [hugohe3/ppt-m The preserved snapshot also contains optional upstream utilities and their documentation. Those legacy utility paths, remote services and optional executables are not this Skill's maintained authoring route or an installation requirement. Use the local native wrapper and host image/UI tools described here; do not invoke an optional utility while assuming its separate service configuration exists. +The maintained wrapper's [math component](mathematics.md) builds native DrawingML Office Math from explicit expression trees using the existing `lxml` dependency. It does not modify the preserved compiler or add a network provider/TeX executable. Math fonts come from the template or a declared project-local math style; destination installation, glyph/MATH-table identity, current rendering and editing remain separate checks. + Page/mockup planning and strict input-asset handling draw on the workflow concepts of [codex-ppt](https://github.com/ningzimu/codex-ppt-skill); object inventory, background/material separation and object-type verification draw on the source-reconstruction workflow available in the development environment. The guidance here is independently written and self-contained. No external workflow script, OCR service, mandatory CLI, review state or installation path is required. Complex automatic asset separation and OCR are not represented as bundled capabilities: use the host visual tools and review the result. The generic wrapper consumes declared palette/font/size data. `preserve_declared_font_families(style)` temporarily protects only those families from the compiler's platform replacement map during a serial compile, restoring process memory on success or failure. It preserves source identity before conversion; it does not replace arbitrary finished-document occurrences of a fallback font. Declared font names do not install, embed or license a font. Verify family, style, version/hash, source, license and actual rendering on the destination device. -For a portable author project, [prepare_native_project.py](../scripts/prepare_native_project.py) copies the required runtime and wrappers into `vendor/native-workflow/`, supplies `tools/native_builder.py`, and records every compiler source as a build input. It accepts an initialized project with an untouched author lock, no builder and an empty dependency/font/tool declaration. It refuses existing destinations and custom environments/builders. It deliberately leaves the old author lock unchanged so the new source must be reviewed and explicitly locked. +For a portable author project, [prepare_native_project.py](../scripts/prepare_native_project.py) copies the required runtime, wrappers, typography/paragraph/spacing and math modules into `vendor/native-workflow/`, supplies `tools/native_builder.py`, and records them as build inputs. It accepts an initialized project with an untouched author lock, no builder and an empty dependency/font/tool declaration. It refuses existing destinations and custom environments/builders. It deliberately leaves the old author lock unchanged so the new source must be reviewed and explicitly locked. If a project-local math style adds a font beyond the template families, add that font declaration to the project environment before locking. The prepared builder reads finalized page SVGs and Markdown speaker notes in project order. It checks the layout IDs and local asset boundaries, then compiles using the vendored template and compiler. `content.json` and declared `data` remain author inputs; this generic builder does not invent a binding schema or automatically regenerate SVG text from those files. If the deck needs data-driven generation, supply a reviewed project-local preprocessing/builder step and declare its inputs before locking. diff --git a/references/spacing.md b/references/spacing.md new file mode 100644 index 0000000..cac1df7 --- /dev/null +++ b/references/spacing.md @@ -0,0 +1,25 @@ +# Fixed spacing + +Spacing belongs to the selected template and shared component recipe. Content callers provide content and whole-container bounds/placement; they do not choose gap values, padding, checking thresholds or one-off variants. If content does not fit, reflow it or choose another supported whole layout/capacity policy. Do not tighten or loosen spacing to fit an instance. + +The external style declares `spacing.policy: "fixed"`, `caller_overrides: false` and named `roles`. Each role has a `kind` (`gap` or `padding`) and a `path` array pointing to its authoritative numeric value in the same style. Values stay external; the generic Skill does not prescribe a template's palette, geometry or organization. A role can point to an existing component field, so values are not duplicated. Padding may be a scalar, `[vertical,horizontal]` or `[top,right,bottom,left]`. + +The template owner fixes role names and values; the recipe fixes which roles apply to each relationship. Consumers cannot invent new spacing variants or edit a locked style to evade the policy. A shared owner change is a versioned template/recipe change and triggers affected validation. An older template without the `spacing` section can use supported fixed bindings to its existing fields; absent fields are reported, never filled with guessed numeric defaults. + +`resolve_spacing(style, role)` returns the declared scalar or normalized four-sided padding. `check_spacing(boxes, checks, *, style)` accepts only roles: + +```python +checks = [ + {"id": "body-gap", "kind": "gap", "a": "heading", "b": "body", + "axis": "y", "role": "heading_body"}, + {"id": "padding", "kind": "padding", "outer": "frame", "inner": "usable", + "role": "text_padding"}, +] +issues = check_spacing(boxes, checks, style=style) +``` + +The checker rejects caller-supplied `minimum`, `expected` or override numbers. Both too-small and too-large distances violate a fixed relationship. A padding relationship compares the full usable inner rectangle against its outer frame; it does not require text to occupy every pixel or fill the bottom of a container. Line-height slots and content height remain derived from complete text. + +`text_block.py` automatically records and checks its fixed text padding and paragraph gaps in `spacing_contract`. Callers cannot alter those values through content data. The same rules apply after a content/width change and to every instance of the type/variant. + +The checker measures declared layout geometry. Actual glyph ink, optical clearances, clipping, mathematical layout and decorative contacts still require the current render. Do not modify a fixed value to disguise a font/layout problem or declare visual acceptance from box equality. diff --git a/references/text-and-containers.md b/references/text-and-containers.md index de111ba..c59f51d 100644 --- a/references/text-and-containers.md +++ b/references/text-and-containers.md @@ -1,6 +1,6 @@ # Text blocks and container layout -Apply C16–C19 in [production rules](production-rules.md) to page authoring, reconstruction, reusable components and collaboration. These are required constraints. A page-library example is a design reference, not an exemption from current rules. Keep fonts, sizes, colors, grid and spacing tied to the selected external template. +Apply C16–C23 in [production rules](production-rules.md) to page authoring, reconstruction, reusable components and collaboration. These are required constraints. A page-library example is a design reference, not an exemption from current rules. Keep fonts, sizes, colors, grid and spacing tied to the selected external template. ## One paragraph, one logical text block @@ -23,6 +23,32 @@ The bundled compiler's default `preserve` text flow retains eligible stacked row For an object check, find the text-bearing `p:sp` objects and their `p:txBody` in the current slide XML. A normal paragraph should occupy one text-bearing shape; multiple `a:r` runs, `a:br` breaks or `a:p` elements inside the same `p:txBody` are internal text structure. Distinct semantic labels remain independent. If the fallback is used, check the whole-block source and fragment mapping as well as native counts. Group/native XML checks do not establish actual application editing or appearance. +## Paragraph line-breaking rules + +Apply punctuation prohibitions at **every line start and end**. Closing punctuation, such as Chinese commas/full stops, closing brackets and closing quotation marks, stays with preceding text; opening brackets and opening quotation marks stay with following text. Do not split paired ellipses (`……`), paired dashes (`——`) or an unbreakable Western word/number merely to fill a line. Use the appropriate punctuation convention for the language and the selected template. + +No paragraph line may consist of only one substantive character after ignoring surrounding punctuation and whitespace. Examples of prohibited lines include `果`, `果。`, `(果)` and `“果”。`. Adding more punctuation or spaces does not make a singleton acceptable. Check **all lines, including the final line**, rather than checking only the paragraph's tail. Punctuation-only lines are also invalid. + +Independent short labels, standalone table values and separate mathematical symbols are not automatically paragraphs. Determine their actual function; do not relabel a sentence fragment as a label to evade this rule. + +Recompute the complete paragraph's break positions. Prefer intact phrases/clauses where they fit; move enough content between neighboring lines to satisfy both punctuation and singleton rules. If the existing bounds cannot support compliant lines, use the template's normal rewrite/layout/capacity policy. Preserve the complete content and logical text block. Do not delete punctuation, silently add words, compress glyphs, invent a smaller font size or independently position the last line. + +For supported plain horizontal text, `scripts/paragraph_layout.py` provides `wrap_paragraph(text, max_width, measure, *, max_lines=None)` and `validate_lines(lines)`. The caller supplies a width-measurement function using the actual selected font and size. The wrapper returns derived rows whose concatenation preserves the input; render them as internal lines of one text frame. Unsupported or impossible input raises an error instead of returning a known-invalid layout. Keep the helper with the shared renderer in the portable project's inputs. + +This helper is not a complete Unicode line-breaking or typesetting engine, and does not automatically run on arbitrary SVG, rich-text runs or PowerPoint objects. Mixed run sizes need measurements for those actual runs; application reflow must be checked in the current render even when the source rows passed validation. The punctuation/widow descriptions in the [W3C Chinese Layout Requirements working draft](https://www.w3.org/TR/clreq/#prohibition_rules_for_line_start_end) and [orphan discussion](https://www.w3.org/TR/clreq/#handling_of_widows_and_orphans) inform the implementation; the user's requirement here explicitly covers every line. + +## Spacing and paragraph separation + +Check more than overlap. Text must not crowd a border, a preceding explanation, a following module or a connector. Check heading/body spacing, four-sided container padding, column/group gaps, and the clearance around separators and formulas. Judge actual glyphs and visible edges at normal viewing scale; a text frame's nominal box may contain extra space or fail to describe its ink. + +Spacing is **fixed per relationship by the selected template and shared recipe**. Content callers cannot supply numeric gaps, minima, arbitrary padding or alternate spacing variants. The template owner provides moderate values and the recipe applies them; callers do not decide how wide a particular instance should look. See the [fixed spacing contract](spacing.md). Resolve crowding by reflow, shorter content or a different supported whole layout/capacity choice, not by changing spacing or shrinking text. Intentionally cropped watermarks, attached arrows and other decorative/semantic contacts retain their own fixed rules. + +Preserve separate paragraphs as separate complete strings or explicit paragraph nodes. Use the fixed positive paragraph gap beyond the normal line-height slots, consistently within the same text role/variant. A line wrap inside one paragraph is not a new paragraph. Prefer native paragraph before/after spacing when multiple paragraphs share a frame; one native frame per paragraph is also supported, with the shared layout deriving all positions. Avoid empty paragraphs, repeated hard breaks and blank text boxes used only as spacers. + +`scripts/text_block.py` provides `render_text_block(data, style, theme, bounds, component_id, measure)`. Data carries `schema_version: 1`, `paragraphs: [complete string, ...]` and optional declared `text_role`/`palette_index`. The renderer reads `components.text_group.padding_px` and `paragraph_gap_px`, resolves the role's line height and uses the measured paragraph helper. It emits one SVG text per paragraph with derived internal rows, rejects height overflow, and retains the full content and layout records in metadata. Paragraph bounds describe line-height slots, not exact font ink. Supply actual-font width measurements; check native frame counts and actual rendered spacing separately. + +`scripts/layout_spacing.py` provides `resolve_spacing(style, role)` and `check_spacing(boxes, checks, *, style)`. Boxes map stable IDs to `[x,y,width,height]`; shared recipes declare directional gaps or padding using template role names. Numeric `minimum`/`expected`/override fields are rejected. Checks compare the exact fixed value, reporting `too_narrow`, `too_wide`, `overlap` or `outside`. Padding compares the outer frame with its complete usable inner area; actual text need not fill that area. Only declared meaningful relationships are checked. Floating-point tolerance is not layout allowance. This geometric check supplements actual visual review. + ## Keep substantive explanations visible Determine importance before assigning `caption`, `detail` or muted styles. Small muted text is for supplementary background, provenance and nonessential definitions. Key conditions, timing, dependencies, conclusions and interpretation-changing limitations belong in readable body, node, callout or heading roles. A qualifying statement is not supplementary merely because it sounds cautious. @@ -61,5 +87,9 @@ Keep the recipe, parameters, content and assets in the portable project's declar 2. Review every small muted note for meaning. Move essential statements into the main hierarchy and verify no qualification disappeared in the move. 3. Compare subheading/body and other distinct roles within each container at normal viewing scale. Font family alone cannot pass. 4. Compare all instances of each type/variant and inspect their creation calls/source. Confirm one shared implementation, no child-coordinate overrides and no post-generation nudges. Equal-looking screenshots alone cannot prove this. +5. Read the actual line starts, line ends and last line of each paragraph. Reject forbidden punctuation positions, split unbreakable marks/words, punctuation-only rows and every singleton row, including a character surrounded by punctuation. Reflow and regenerate the whole logical paragraph when changing content, width or typography. +6. Check the template's exact fixed values for text padding, heading/body, adjacent-group and formula/connector spacing. Verify no numeric spacing overrides or instance variants entered the caller input. Check actual glyph/edge clearance separately; non-overlap alone is insufficient. +7. Locate semantic paragraph boundaries in the content source and render. Confirm visible extra separation between paragraphs while maintaining normal line spacing within each paragraph; no dummy empty paragraphs or manually positioned spacer objects. +8. For mathematics, inspect [font and equation structure](mathematics.md), then the actual render. Confirm scripts/fractions/limits have not become ordinary unformatted text or a picture. The compiler cannot reliably infer these semantic properties from arbitrary SVG. Combine source review, native object checks and current rendered-page review; mark unavailable application checks explicitly. diff --git a/references/visual-design.md b/references/visual-design.md index d029306..d7c2a75 100644 --- a/references/visual-design.md +++ b/references/visual-design.md @@ -18,6 +18,8 @@ Inspect local reference images using the available image-viewing tool before edi Use generated text only as visual guidance. Native source text comes from verified content, with the template's declared fonts and sizes; do not copy misspellings or typography defects from the raster sample. Preserve original evidence/identity assets independently so the final native page can use those files exactly. +Native paragraph lines must satisfy [punctuation and singleton rules](text-and-containers.md#paragraph-line-breaking-rules). Do not preserve a mockup's forbidden line-head/tail punctuation or a row containing only one character with optional punctuation; reflow the complete paragraph within the declared layout. + ## Material generation and transparent assets For new bitmap illustrations, photos, textures or image edits, use the selected host image-generation capability. Request a transparent background when the final asset needs transparency. Preserve transparency on edits unless the user asks to change it. Use reference-image paths when all targets have paths, otherwise the smallest supported recent-image set that includes every target; do not silently omit a required target. diff --git a/scripts/basic_component.py b/scripts/basic_component.py index d9427f7..81c893f 100644 --- a/scripts/basic_component.py +++ b/scripts/basic_component.py @@ -10,6 +10,7 @@ import sys import xml.etree.ElementTree as ET import template_ref as ref +from component_typography import resolve_role SVG = "http://www.w3.org/2000/svg" ET.register_namespace("", SVG) @@ -19,9 +20,9 @@ def node(parent, tag, **attributes): return ET.SubElement(parent, f"{{{SVG}}}{tag}", {key.replace("_", "-"): str(value) for key, value in attributes.items()}) -def label(parent, value, x, y, font, size, color, anchor="start"): +def label(parent, value, x, y, font, size, color, anchor="start", weight="normal"): result = node(parent, "text", x=x, y=y, font_family=font, font_size=size, - font_weight="normal", fill=color, text_anchor=anchor) + font_weight=weight, fill=color, text_anchor=anchor) result.text = str(value) return result @@ -53,12 +54,13 @@ def render_component(data: dict, style: dict, theme: dict, bounds: tuple, ref.require(x >= 0 and y >= 0 and w > 0 and h > 0 and x+w <= canvas["width"] and y+h <= canvas["height"], "bounds outside canvas") kind = data.get("kind") ref.require(kind in ("table", "metric", "timeline", "flow"), "unsupported basic component") - palette, roles = theme["palette"], style["typography"]["roles"] - families = style["typography"]["families"] - font = families[roles["label"]["family_role"]]["family"] - ratio = canvas["pt_to_px"] - size = roles["label"]["size_pt"] * ratio - small = roles["caption"]["size_pt"] * ratio + if kind == "table" and data.get("variant") == "grouped": + from table_component import render_grouped_table + return render_grouped_table(data, style, theme, bounds, component_id) + palette = theme["palette"] + label_role = resolve_role(style, "label") + caption_role = resolve_role(style, "caption") + font, size, small = label_role.family, label_role.size, caption_role.size ink, muted, accent = palette["dk1"], palette["dk2"], palette["accent1"] line, white = palette["line"], palette["lt1"] group = ET.Element(f"{{{SVG}}}g", {"id": component_id, "data-component-kind": kind}) @@ -67,9 +69,6 @@ def render_component(data: dict, style: dict, theme: dict, bounds: tuple, if kind == "table": ref.require(data.get("variant", "basic") in ("basic", "grouped"), "unsupported table variant") - if data.get("variant") == "grouped": - from table_component import render_grouped_table - return render_grouped_table(data, style, theme, bounds, component_id) columns, rows = data.get("columns"), data.get("rows") ref.require(isinstance(columns, list) and columns, "table.columns requires labels") ref.require(isinstance(rows, list) and rows, "table.rows requires data") @@ -82,8 +81,11 @@ def render_component(data: dict, style: dict, theme: dict, bounds: tuple, else: ref.require(not any(ord(char) < 32 for char in value), "cell: control characters unsupported") cfg = style["components"]["table"] + table_name = cfg.get("text_role", "label") + table_role = label_role if table_name == "label" else resolve_role(style, table_name) + font, size = table_role.family, table_role.size top, side = cfg["cell_padding_px"] - row_height = math.ceil((size * roles["label"].get("line_height", 1.25) + 2*top) / 8) * 8 + row_height = math.ceil((table_role.line_size + 2*top) / 8) * 8 ref.require(row_height*(len(rows)+1) <= h, "table rows exceed height") widths = data.get("column_widths", [w/len(columns)] * len(columns)) ref.require(isinstance(widths, list) and len(widths) == len(columns), "column_widths mismatch") @@ -100,7 +102,7 @@ def render_component(data: dict, style: dict, theme: dict, bounds: tuple, fill = palette[cfg["header_fill_palette_index"]] if i == 0 else white align = "r" if i and j in numeric else "l" cell = {"text": str(value), "fill": fill, "color": color, "align": align, - "font_size": size, "bold": False, "padding": {"top": top, "bottom": top, "left": side, "right": side}, + "font_family": font, "font_size": size, "bold": table_role.bold, "padding": {"top": top, "bottom": top, "left": side, "right": side}, "border_color": line, "border_width": 1} cells.append(cell) fit(value, widths[j]-2*side, size, "table cell") @@ -108,7 +110,7 @@ def render_component(data: dict, style: dict, theme: dict, bounds: tuple, fill=fill, stroke=line, stroke_width=1) tx = offset+widths[j]-side if align == "r" else offset+side label(group, value, tx, y+i*row_height+(row_height+size*.72)/2, font, size, color, - "end" if align == "r" else "start") + "end" if align == "r" else "start", table_role.svg_weight) offset += widths[j] table_rows.append(cells) group.set("data-pptx-replace-with", "table") @@ -129,17 +131,19 @@ def render_component(data: dict, style: dict, theme: dict, bounds: tuple, detail = data.get("detail", "") ref.require(isinstance(unit, str) and isinstance(detail, str), "metric unit/detail must be strings") ref.require(not any(ord(char) < 32 for char in unit+detail), "metric: control characters unsupported") - big = roles["number"]["size_pt"] * ratio + number_role = resolve_role(style, "number") + big = number_role.size ref.require(h >= 160, "metric needs at least 160px height") fit(title, w, size, "metric label") fit(str(value)+unit, w, big, "metric value") fit(detail, w, small, "metric detail") - label(group, title, x, y+size, font, size, muted) - value_node = label(group, value, x, y+104, font, big, accent) + label(group, title, x, y+size, font, size, muted, weight=label_role.svg_weight) + value_node = label(group, value, x, y+104, number_role.family, big, accent, weight=number_role.svg_weight) if unit: - span = node(value_node, "tspan", dx=16, font_size=size, fill=muted) + span = node(value_node, "tspan", dx=16, font_family=font, font_size=size, + font_weight=label_role.svg_weight, fill=muted) span.text = unit - if detail: label(group, detail, x, y+152, font, small, ink) + if detail: label(group, detail, x, y+152, caption_role.family, small, ink, weight=caption_role.svg_weight) return group items = data.get("items") if kind == "timeline" else data.get("nodes") @@ -156,8 +160,8 @@ def render_component(data: dict, style: dict, theme: dict, bounds: tuple, fit(title, slot-24, size, "timeline label") fit(date, slot-24, small, "timeline date") node(group, "circle", cx=centers[i], cy=cy, r=8, fill=accent, stroke=white, stroke_width=2) - label(group, date, centers[i], y+56, font, small, muted, "middle") - label(group, title, centers[i], y+152, font, size, ink, "middle") + label(group, date, centers[i], y+56, caption_role.family, small, muted, "middle", caption_role.svg_weight) + label(group, title, centers[i], y+152, font, size, ink, "middle", label_role.svg_weight) return group ref.require(h >= 160, "flow needs at least 160px height") @@ -181,7 +185,7 @@ def render_component(data: dict, style: dict, theme: dict, bounds: tuple, nx = x+i*(nw+gap) title = text(item.get("label"), "flow label");fit(title, nw-24, size, "flow label") node(group, "rect", x=nx, y=cy-40, width=nw, height=80, fill=white, stroke=muted, stroke_width=2) - label(group, title, nx+nw/2, cy+size*.36, font, size, ink, "middle") + label(group, title, nx+nw/2, cy+size*.36, font, size, ink, "middle", label_role.svg_weight) if i < len(items)-1 and (ids[i], ids[i+1]) in actual: end = nx+nw+gap node(group, "line", x1=nx+nw, y1=cy, x2=end-8, y2=cy, stroke=muted, stroke_width=2) diff --git a/scripts/chart_component.py b/scripts/chart_component.py index 4e107b9..bd89d2a 100644 --- a/scripts/chart_component.py +++ b/scripts/chart_component.py @@ -13,6 +13,7 @@ import unicodedata import xml.etree.ElementTree as ET import template_ref as ref +from component_typography import resolve_role SVG = "http://www.w3.org/2000/svg" ET.register_namespace("", SVG) @@ -101,8 +102,10 @@ def _node(parent, tag, **attrs): return ET.SubElement(parent, '{' + SVG + '}' + tag, {k.replace('_', '-'): _n(v) if isinstance(v, (float, int)) else str(v) for k, v in attrs.items()}) -def _text(parent, x, y, value, font, size, color, anchor='start'): +def _text(parent, x, y, value, font, size, color, anchor='start', weight=None): node = _node(parent, 'text', x=x, y=y, fill=color, font_family=font, font_size=size, text_anchor=anchor) + if weight is not None: + node.set('font-weight', str(weight)) node.text = value return node @@ -153,22 +156,20 @@ def _style(style, theme): typography = style['typography'] role = chart.get('text_role', 'caption' if 'caption' in typography['roles'] else 'label') require(role in typography['roles'], 'chart text_role missing from typography.roles') - label = typography['roles'][role] - family = typography['families']['body']['family'] - px_per_pt = number(style['canvas']['pt_to_px'], 'canvas.pt_to_px') - require(px_per_pt > 0, 'canvas.pt_to_px must be positive') - size = number(label['size_pt'], 'chart size_pt') * px_per_pt - require(size > 0, 'chart text size must be positive') + caption = resolve_role(style, role) + title = resolve_role(style, 'label' if 'label' in typography['roles'] else role) palette = theme['palette'] indices = chart.get('series_palette_indices', [k for k in ('accent1', 'accent3', 'accent4', 'accent5', 'accent6', 'accent2') if k in palette]) require(isinstance(indices, list) and bool(indices), 'series_palette_indices must be nonempty list') require(len(set(indices)) == len(indices), 'series_palette_indices must be unique') - keys = [*indices, chart.get('text_palette_index', 'dk1'), chart.get('grid_palette_index', 'line' if 'line' in palette else 'dk2'), 'lt1'] + keys = [*indices, chart.get('text_palette_index', 'dk1'), chart.get('grid_palette_index', 'line' if 'line' in palette else 'dk2'), 'lt1', 'accent2', 'dk2'] axis_index = chart.get('axis_palette_index', chart.get('text_palette_index', 'dk1')) require(isinstance(axis_index, str) and axis_index in palette and re.fullmatch(r'#[0-9A-Fa-f]{6}', palette[axis_index]), 'invalid/missing axis_palette_index') for key in keys: require(isinstance(key, str) and key in palette and re.fullmatch(r'#[0-9A-Fa-f]{6}', palette[key]), f'invalid/missing palette color: {key}') - return family, size, [palette[k] for k in indices], palette[keys[-3]], palette[keys[-2]], palette['lt1'], palette[axis_index] + return (title, caption, [palette[k] for k in indices], palette[chart.get('text_palette_index', 'dk1')], + palette[chart.get('grid_palette_index', 'line' if 'line' in palette else 'dk2')], + palette['lt1'], palette[axis_index], palette['accent2'], palette['dk2']) def render_chart(data: dict, style: dict, theme: dict, @@ -186,7 +187,8 @@ def render_chart(data: dict, style: dict, theme: dict, require(x >= 0 and y >= 0 and w > 0 and h > 0, 'bounds must have nonnegative origin and positive size') canvas = style['canvas'] require(x + w <= canvas['width'] and y + h <= canvas['height'], 'component bounds exceed template canvas') - font, fs, colors, ink, grid, background, axis_color = _style(style, theme) + title_role, caption_role, colors, ink, grid, background, axis_color, title_color, source_color = _style(style, theme) + font, fs = caption_role.family, caption_role.size require(w >= fs * 14 and h >= fs * 14, 'bounds too small for declared chart font and annotations') kind = data['type'] value_labels = style.get('components', {}).get('charts', {}).get('value_labels', False) @@ -196,6 +198,8 @@ def render_chart(data: dict, style: dict, theme: dict, require(all(value >= 0 for series in data['series'] for value in series['values']), 'optional column value_labels requires nonnegative values') pie = kind in ('pie', 'doughnut') + require(pie or caption_role.weight == 400, + 'native chart axes support Regular (400) weight only; selected chart text role requires an unsupported weight') count = len(data['categories']) if pie else len(data['series']) require(count <= len(colors), 'insufficient distinct palette indices for series/slices; configure style.components.charts') colors = colors[:count] @@ -212,17 +216,20 @@ def render_chart(data: dict, style: dict, theme: dict, unit = ';'.join(unit_parts) else: unit = '单位:' + data['unit'] if data['unit'] else '' - for value, name in ((data['title'], 'title'), (unit, 'unit'), (source_line, 'source')): + _fit(data['title'], title_role.size, w - 16, 'title') + for value, name in ((unit, 'unit'), (source_line, 'source')): _fit(value, fs, w - 16, name) - outer = ET.Element('{' + SVG + '}g', {'id': component_id, 'data-component': 'chart', 'data-chart-type': kind}) - _text(outer, x + 8, y + fs, data['title'], font, fs, ink) - top = y + math.ceil((fs + 16) / 8) * 8 + outer = ET.Element('{' + SVG + '}g', {'id': component_id, 'data-component': 'chart', + 'data-chart-type': kind, 'font-weight': caption_role.svg_weight}) + _text(outer, x + 8, y + title_role.size, data['title'], title_role.family, + title_role.size, title_color, weight=title_role.svg_weight) + top = y + math.ceil((title_role.line_size + 8) / 8) * 8 if unit: _text(outer, x + 8, top + fs, unit, font, fs, ink) - top += math.ceil((fs + 8) / 8) * 8 - bottom = y + h - (math.ceil((fs + 8) / 8) * 8 if source_line else 8) + top += math.ceil((caption_role.line_size + 8) / 8) * 8 + bottom = y + h - (math.ceil((caption_role.line_size + 8) / 8) * 8 if source_line else 8) if source_line: - _text(outer, x + 8, y + h - 8, source_line, font, fs, ink) + _text(outer, x + 8, y + h - 8, source_line, font, fs, source_color) legends = [f'{c} {_label(v)}' for c, v in zip(data.get('categories', []), data['series'][0].get('values', []))] if pie else [s['name'] for s in data['series']] legend_widths = [32 + _width(label, fs) + 16 for label in legends] @@ -235,11 +242,12 @@ def render_chart(data: dict, style: dict, theme: dict, row.append((index, width));used += width if row: rows.append(row) - legend_h = len(rows) * math.ceil((fs + 8) / 8) * 8 + legend_step = math.ceil((caption_role.line_size + 8) / 8) * 8 + legend_h = len(rows) * legend_step legend_y = bottom - legend_h for r, items in enumerate(rows): lx = x + 8 - ly = legend_y + r * math.ceil((fs + 8) / 8) * 8 + fs + ly = legend_y + r * legend_step + fs for idx, width in items: _node(outer, 'rect', x=lx, y=ly-fs*.65, width=16, height=16, fill=colors[idx]) _text(outer, lx+24, ly, legends[idx], font, fs, ink) @@ -257,7 +265,7 @@ def render_chart(data: dict, style: dict, theme: dict, metadata = _node(marker, 'metadata', type='application/json') if value_labels: payload['data_labels'] = {'position': 'outside_end', 'show_value': True, - 'font_size': fs, 'color': ink, 'number_format': 'General'} + 'font_size': fs, 'color': ink, 'bold': caption_role.bold, 'number_format': 'General'} if kind == 'line': payload['markers'] = True fallback = _node(marker, 'g', id=component_id+'-fallback') @@ -280,7 +288,8 @@ def render_chart(data: dict, style: dict, theme: dict, if kind != 'bar': payload['axes'] = {'category': {'label_position': 'low', 'major_gridlines': False}, 'value': _axis(yscale)} else: - payload['data_labels'] = {'position': 'outside_end'} + payload['data_labels'] = {'position': 'outside_end', 'font_size': fs, + 'color': ink, 'bold': caption_role.bold} payload['show_value_axis_labels'] = True outer.set('data-native-axis-policy', 'automatic-bar-preview-scale-may-differ') if pie: @@ -318,7 +327,8 @@ def render_chart(data: dict, style: dict, theme: dict, _text(fallback, xp(tick), pb+fs+8, _label(tick), font, fs, ink, 'middle') band = (pb-pt)/len(data['categories']) bw = band/(len(data['series'])+1.5) - require(band >= fs+8 and bw >= fs+4, 'too many bar categories/series for fixed labels and bounds') + require(band >= max(fs+8, caption_role.line_size) and bw >= max(fs+4, caption_role.line_size), + 'too many bar categories/series for fixed labels and bounds') for i, category in enumerate(data['categories']): center = pt+(i+.5)*band _text(fallback, x+labels_width-8, center+fs*.35, category, font, fs, ink, 'end') @@ -333,7 +343,8 @@ def render_chart(data: dict, style: dict, theme: dict, tick_width = max(_width(_label(t), fs) for t in ticks) left, right = x+tick_width+16, x+w-16 pt, pb = top+8, top+chart_h-fs-16 - require(right-left >= fs*7 and (pb-pt)/(len(ticks)-1) >= fs+4, 'axis labels leave insufficient plot area') + require(right-left >= fs*7 and (pb-pt)/(len(ticks)-1) >= max(fs+4, caption_role.line_size), + 'axis labels leave insufficient plot area') low, high, step = yscale yp = lambda val: pb-(val-low)/(high-low)*(pb-pt) for tick in ticks: diff --git a/scripts/component_typography.py b/scripts/component_typography.py new file mode 100644 index 0000000..ef8930d --- /dev/null +++ b/scripts/component_typography.py @@ -0,0 +1,52 @@ +"""Resolve template text roles once for reuse within a component render.""" +from __future__ import annotations + +from dataclasses import dataclass +import math + +import template_ref as ref + + +@dataclass(frozen=True) +class TextRole: + family: str + size: float + weight: int + line_height: float + + @property + def svg_weight(self): + return "normal" if self.weight == 400 else "bold" if self.weight == 700 else str(self.weight) + + @property + def bold(self): + return self.weight >= 600 + + @property + def line_size(self): + return self.size * self.line_height + + +def resolve_role(style: dict, name: str) -> TextRole: + typography = style["typography"] + ref.require(name in typography["roles"], f"unknown typography role: {name}") + role = typography["roles"][name] + family = typography["families"][role["family_role"]] + face = family["family"] + ref.require(isinstance(face, str) and bool(face.strip()), f"{name}: font family required") + ratio, points = style["canvas"]["pt_to_px"], role["size_pt"] + line_height = role.get("line_height", 1.25) + for value, label in ((ratio, "pt_to_px"), (points, "size_pt"), (line_height, "line_height")): + ref.require(type(value) in (int, float) and math.isfinite(value) and value > 0, + f"{name}: positive finite {label} required") + # A role can refine its family weight without losing the family's defaults. + raw = role.get("weight_number", role.get("weight", family.get("weight_number", family.get("weight", "Regular")))) + if isinstance(raw, str): + weights = {"thin": 100, "extralight": 200, "light": 300, "regular": 400, + "normal": 400, "medium": 500, "semibold": 600, "bold": 700, + "extrabold": 800, "black": 900} + raw = weights.get(raw.lower().replace(" ", "").replace("-", ""), raw) + if isinstance(raw, str) and raw.isdigit(): + raw = int(raw) + ref.require(type(raw) is int and 1 <= raw <= 1000, f"{name}: invalid font weight") + return TextRole(face, points * ratio, raw, line_height) diff --git a/scripts/layout_spacing.py b/scripts/layout_spacing.py new file mode 100644 index 0000000..3e03378 --- /dev/null +++ b/scripts/layout_spacing.py @@ -0,0 +1,159 @@ +"""Resolve and check fixed template spacing, without caller-owned thresholds. + +Semantic relations name spacing roles from the frozen style. Padding applies +to the complete available inner region, not occupied text/ink bounds. Legacy +templates read existing fields through fixed paths, without numeric defaults. +""" +from __future__ import annotations + +import math + + +_LEGACY_ROLES = { + "text_padding": ("padding", ("components", "text_group", "padding_px")), + "paragraph": ("gap", ("components", "text_group", "paragraph_gap_px")), + "item": ("gap", ("components", "text_group", "item_gap_px")), + "columns": ("gap", ("grid", "gutter")), + "panel_padding": ("padding", ("components", "panel", "padding_px")), + "icon_text": ("gap", ("icons", "text_gap_px")), + "basic_table_cell_padding": ("padding", ("components", "table", "cell_padding_px")), + "grouped_table_cell_padding": ("padding", ("components", "table_grouped", "cell_padding_px")), + **{name: ("gap", ("components", "spacing", name + "_px")) + for name in ("heading_body", "content_group", "separator", "formula", "connector_clearance")}, +} + + +def _number(value, label): + if type(value) not in (int, float) or not math.isfinite(value) or value < 0: + raise ValueError(f"{label}: expected nonnegative finite number") + return float(value) + + +def _name(value, label): + if not isinstance(value, str) or not value.strip(): + raise ValueError(f"{label}: expected nonempty ID") + return value + + +def _padding(value, label): + if type(value) in (int, float): + return (_number(value, label),) * 4 + if not isinstance(value, (list, tuple)) or len(value) not in (2, 4): + raise ValueError(f"{label}: padding requires scalar, [vertical,horizontal] or [top,right,bottom,left]") + values = tuple(_number(item, label) for item in value) + return (values[0], values[1], values[0], values[1]) if len(values) == 2 else values + + +def resolve_spacing(style: dict, role: str) -> float | tuple[float, float, float, float]: + """Read an exact gap/padding from a frozen template's named role. + + New declarations require spacing.policy == 'fixed' and + spacing.roles[role] == {kind:'gap'|'padding', path:[field,...]}. + Paths traverse dictionary keys only, never executable JSONPath. Without a + spacing section, known legacy roles read only their existing fixed paths. + """ + if not isinstance(style, dict): + raise ValueError("style must be an object") + _name(role, "spacing role") + if "spacing" in style: + spacing = style["spacing"] + if not isinstance(spacing, dict) or spacing.get("policy") != "fixed": + raise ValueError("spacing.policy must be fixed") + if spacing.get("caller_overrides", False) is not False: + raise ValueError("fixed spacing forbids caller overrides") + roles = spacing.get("roles") + if not isinstance(roles, dict) or role not in roles: + raise ValueError(f"unknown fixed spacing role: {role}") + declaration = roles[role] + if not isinstance(declaration, dict) or set(declaration) != {"kind", "path"}: + raise ValueError(f"{role}: spacing role requires kind/path only") + kind, path = declaration["kind"], declaration["path"] + if kind not in ("gap", "padding"): + raise ValueError(f"{role}: spacing kind must be gap or padding") + if not isinstance(path, list) or not path or any(not isinstance(key, str) or not key for key in path): + raise ValueError(f"{role}: spacing path requires nonempty field-name list") + else: + if role not in _LEGACY_ROLES: + raise ValueError(f"unknown legacy spacing role: {role}") + kind, path = _LEGACY_ROLES[role] + value = style + for key in path: + if not isinstance(value, dict) or key not in value: + raise ValueError(f"{role}: missing spacing field at {'.'.join(path)}") + value = value[key] + return _number(value, role) if kind == "gap" else _padding(value, role) + + +def check_spacing(boxes: dict, checks: list, *, style: dict) -> list[dict]: + """Return deviations from exact template-owned directional gaps/padding. + + Gap checks use ``{id, kind:'gap', a, b, axis:'x'|'y', role}``, measured + from a's right/bottom edge to b's left/top edge. Padding checks use + ``{id, kind:'padding', outer, inner, role}``, where inner is the complete + usable region. Numeric minimum/expected/override fields are rejected. + Negative distances report ``overlap`` (gap) or ``outside`` (padding); + other deviations report ``too_narrow`` or ``too_wide``. Absolute tolerance + 1e-7 absorbs arithmetic roundoff only, not extra layout space. Invalid inputs, + missing IDs or self-relations raise ValueError. These are geometry checks, + not font ink measurements or visual acceptance. + """ + if not isinstance(boxes, dict) or not isinstance(checks, list): + raise ValueError("boxes must be a mapping and checks must be a list") + geometry = {} + for key, bounds in boxes.items(): + _name(key, "box ID") + if not isinstance(bounds, (list, tuple)) or len(bounds) != 4: + raise ValueError(f"{key}: bounds requires [x,y,width,height]") + geometry[key] = tuple(_number(value, f"{key}.bounds") for value in bounds) + + def pair(check, first, second): + a, b = _name(check[first], first), _name(check[second], second) + if a == b: + raise ValueError(f"{check['id']}: relation must use two different boxes") + if a not in geometry or b not in geometry: + raise ValueError(f"{check['id']}: relation references unknown box") + return geometry[a], geometry[b] + + result, seen = [], set() + for check in checks: + if not isinstance(check, dict): + raise ValueError("each spacing check must be an object") + key = _name(check.get("id"), "check ID") + if key in seen: + raise ValueError(f"duplicate check ID: {key}") + seen.add(key) + kind = check.get("kind") + if kind == "gap": + if set(check) != {"id", "kind", "a", "b", "axis", "role"}: + raise ValueError(f"{key}: gap requires id/kind/a/b/axis/role only; numeric overrides forbidden") + if check["axis"] not in ("x", "y"): + raise ValueError(f"{key}: axis must be x or y") + a, b = pair(check, "a", "b") + expected = resolve_spacing(style, check["role"]) + if not isinstance(expected, float): + raise ValueError(f"{key}: gap relation requires a gap role") + index = 0 if check["axis"] == "x" else 1 + actual = b[index] - a[index] - a[index + 2] + # Derived line heights can differ by floating-point roundoff only. + if not math.isclose(actual, expected, rel_tol=0, abs_tol=1e-7): + result.append({**check, "actual": actual, "expected": expected, + "code": "overlap" if actual < 0 else "too_narrow" if actual < expected else "too_wide"}) + elif kind == "padding": + if set(check) != {"id", "kind", "outer", "inner", "role"}: + raise ValueError(f"{key}: padding requires id/kind/outer/inner/role only; numeric overrides forbidden") + outer, inner = pair(check, "outer", "inner") + expected = resolve_spacing(style, check["role"]) + if not isinstance(expected, tuple): + raise ValueError(f"{key}: padding relation requires a padding role") + ox, oy, ow, oh = outer + ix, iy, iw, ih = inner + actuals = (iy - oy, ox + ow - ix - iw, oy + oh - iy - ih, ix - ox) + for side, actual, target in zip(("top", "right", "bottom", "left"), actuals, expected): + if not math.isclose(actual, target, rel_tol=0, abs_tol=1e-7): + result.append({"id": key, "kind": kind, "outer": check["outer"], + "inner": check["inner"], "role": check["role"], "side": side, + "actual": actual, "expected": target, + "code": "outside" if actual < 0 else "too_narrow" if actual < target else "too_wide"}) + else: + raise ValueError(f"{key}: unsupported spacing check kind") + return result diff --git a/scripts/math_component.py b/scripts/math_component.py new file mode 100644 index 0000000..6fa594f --- /dev/null +++ b/scripts/math_component.py @@ -0,0 +1,413 @@ +"""Build explicit mathematical expressions as native PowerPoint Office Math. + +Structured expressions are the source of truth. This is not a TeX parser. +The wrapper replaces a temporary compiler token with OMML after compilation; +the preserved upstream compiler is unchanged. A linear text fallback is kept +for applications without Office Math support, not claimed as an equation. +""" +from __future__ import annotations + +import copy +import hashlib +import json +import math +from pathlib import Path +import re +import tempfile +import unicodedata +import zipfile +import xml.etree.ElementTree as SVG_ET + +from lxml import etree as ET + +from component_typography import resolve_role +import template_ref as ref + +SVG = "http://www.w3.org/2000/svg" +A = "http://schemas.openxmlformats.org/drawingml/2006/main" +P = "http://schemas.openxmlformats.org/presentationml/2006/main" +M = "http://schemas.openxmlformats.org/officeDocument/2006/math" +A14 = "http://schemas.microsoft.com/office/drawing/2010/main" +MC = "http://schemas.openxmlformats.org/markup-compatibility/2006" +NS = {"a": A, "p": P, "m": M, "a14": A14, "mc": MC} +TOKEN_PREFIX = "NATIVE_MATH_" + + +def _finite(value, name, *, positive=False): + ref.require(type(value) in (int, float) and math.isfinite(value) + and (value > 0 if positive else value >= 0), + f"{name}: expected {'positive' if positive else 'nonnegative'} finite number") + return float(value) + + +def _text(value): + ref.require(isinstance(value, str) and value.strip(), "math requires nonempty text") + ref.require(not any(unicodedata.category(c) in ("Cc", "Cf") for c in value), + "math text cannot contain control/format characters or line breaks") + + +def validate_expression(expression): + """Reject unknown constructs rather than silently changing mathematics.""" + budget = [0, 0] + + def visit(expr, depth): + budget[0] += 1 + ref.require(depth <= 32 and budget[0] <= 1000, "math expression exceeds nesting/node limit") + if isinstance(expr, str): + _text(expr) + budget[1] += len(expr) + ref.require(budget[1] <= 8192, "math expression text exceeds limit") + return + ref.require(isinstance(expr, dict), "math expression requires a string or structured node") + kind = expr.get("type") + fields = { + "row": ({"type", "items"}, set()), + "text": ({"type", "text"}, {"style"}), + "fraction": ({"type", "numerator", "denominator"}, set()), + "script": ({"type", "base"}, {"sub", "sup"}), + "radical": ({"type", "body"}, {"degree"}), + "delimiter": ({"type", "body", "open", "close"}, set()), + "nary": ({"type", "operator", "body"}, {"lower", "upper"}), + "matrix": ({"type", "rows"}, set()), + } + ref.require(kind in fields, f"unsupported math node: {kind}") + required, optional = fields[kind] + ref.require(required <= set(expr) <= required | optional, f"{kind}: invalid fields") + if kind == "text": + ref.require(expr.get("style", "plain") in ("plain", "italic", "bold", "bold-italic"), + "math text style invalid") + _text(expr["text"]) + visit(expr["text"], depth + 1) + elif kind == "row": + ref.require(isinstance(expr["items"], list) and expr["items"], "math row requires items") + for value in expr["items"]: + visit(value, depth + 1) + elif kind == "matrix": + rows = expr["rows"] + ref.require(isinstance(rows, list) and 1 <= len(rows) <= 10 + and all(isinstance(row, list) and 1 <= len(row) <= 10 for row in rows), + "math matrix requires 1–10 rows/columns") + ref.require(len({len(row) for row in rows}) == 1, "math matrix rows must have equal length") + for row in rows: + for value in row: + visit(value, depth + 1) + else: + if kind == "script": + ref.require("sub" in expr or "sup" in expr, "math script requires sub or sup") + if kind == "delimiter": + ref.require(expr["open"] in ("(", "[", "{", "|", "‖", "⌈", "⌊", "⟨") + and expr["close"] in (")", "]", "}", "|", "‖", "⌉", "⌋", "⟩"), + "math delimiter unsupported") + if kind == "nary": + ref.require(expr["operator"] in ("∑", "∏", "∫", "⋂", "⋃"), "math n-ary operator unsupported") + children = {"fraction": ("numerator", "denominator"), + "script": ("base", "sub", "sup"), + "radical": ("body", "degree"), + "delimiter": ("body",), "nary": ("lower", "upper", "body")}[kind] + for key in children: + if key in expr: + visit(expr[key], depth + 1) + + visit(expression, 0) + + +def linear_text(expr): + """An unambiguous linear compatibility view, not a layout approximation.""" + if isinstance(expr, str): + return expr + kind = expr["type"] + if kind == "text": + return expr["text"] + if kind == "row": + return "".join(linear_text(value) for value in expr["items"]) + if kind == "fraction": + return f"({linear_text(expr['numerator'])})/({linear_text(expr['denominator'])})" + if kind == "script": + result = f"({linear_text(expr['base'])})" + return result + "".join(("_" if key == "sub" else "^") + "(" + linear_text(expr[key]) + ")" + for key in ("sub", "sup") if key in expr) + if kind == "radical": + degree = "[" + linear_text(expr["degree"]) + "]" if "degree" in expr else "" + return "√" + degree + "(" + linear_text(expr["body"]) + ")" + if kind == "delimiter": + return expr["open"] + linear_text(expr["body"]) + expr["close"] + if kind == "nary": + limits = "".join(("_" if key == "lower" else "^") + "(" + linear_text(expr[key]) + ")" + for key in ("lower", "upper") if key in expr) + return expr["operator"] + limits + "(" + linear_text(expr["body"]) + ")" + return "[" + "; ".join(", ".join(linear_text(value) for value in row) for row in expr["rows"]) + "]" + + +def _validate_spec(spec, canvas=None): + expected = {"schema_version", "id", "expression", "bounds", "font_family", "font_size_pt", + "color", "display", "align"} + ref.require(isinstance(spec, dict) and set(spec) == expected + and type(spec["schema_version"]) is int and spec["schema_version"] == 1, + "native math requires schema_version 1 and declared fields") + ref.identifier(spec["id"], "math ID") + validate_expression(spec["expression"]) + bounds = spec["bounds"] + ref.require(isinstance(bounds, (list, tuple)) and len(bounds) == 4, "math bounds requires x/y/width/height") + x, y = [_finite(v, "math position") for v in bounds[:2]] + w, h = [_finite(v, "math size", positive=True) for v in bounds[2:]] + if canvas: + ref.require(x + w <= canvas["width"] and y + h <= canvas["height"], "math outside canvas") + _text(spec["font_family"]) + ref.require(_finite(spec["font_size_pt"], "math font size", positive=True) <= 400, + "math font size outside supported range") + ref.require(isinstance(spec["color"], str) and re.fullmatch(r"#[0-9a-fA-F]{6}", spec["color"]), + "math requires an explicit RGB color") + ref.require(spec["display"] in ("block", "inline") and spec["align"] in ("left", "center", "right"), + "math display/alignment invalid") + + +def render_math(data, style, theme, bounds, component_id, math_style=None): + """Return a source group; font comes from a template or explicit project input. + + math_style may declare font_family/text_role/palette_index externally when + a template has not yet introduced math roles. It is a locked build input, + not a global font change. Inline creates a standalone equation frame; this + API does not yet combine prose and math inside a flowing text paragraph. + """ + ref.require(isinstance(data, dict) and type(data.get("schema_version")) is int and data["schema_version"] == 1 + and {"schema_version", "expression"} <= set(data) + <= {"schema_version", "expression", "display", "align"}, "invalid math component data") + display = data.get("display", "block") + math_style = math_style or {} + ref.require(isinstance(math_style, dict) and set(math_style) <= {"font_family", "text_role", "palette_index"}, + "invalid math style declaration") + family = math_style.get("font_family") or style["typography"]["families"].get("math", {}).get("family") + ref.require(family, "Declare a math font in template typography.families.math or project math_style") + role_name = math_style.get("text_role", "math_display" if display == "block" else "math_inline") + role = resolve_role(style, role_name) + spec = {"schema_version": 1, "id": component_id, "expression": data["expression"], "bounds": list(bounds), + "font_family": family, "font_size_pt": role.size / style["canvas"]["pt_to_px"], + "color": theme["palette"][math_style.get("palette_index", "dk1")], + "display": display, "align": data.get("align", "left")} + _validate_spec(spec, style["canvas"]) + group = SVG_ET.Element("{" + SVG + "}g", {"id": component_id, "data-native-math": "1"}) + metadata = SVG_ET.SubElement(group, "{" + SVG + "}metadata", {"type": "application/json"}) + metadata.text = json.dumps(spec, ensure_ascii=False) + x, y, w, h = bounds + anchor = {"left": "start", "center": "middle", "right": "end"}[spec["align"]] + tx = x if anchor == "start" else x + w / 2 if anchor == "middle" else x + w + node = SVG_ET.SubElement(group, "{" + SVG + "}text", {"x": str(tx), "y": str(y + h / 2 + role.size * .32), + "font-family": family, "font-size": str(role.size), "fill": spec["color"], "text-anchor": anchor}) + node.text = linear_text(data["expression"]) + return group + + +def prepare_math(root, canvas): + """Replace source math groups with unique temporary compiler tokens.""" + specs, ids = [], set() + + def walk(element, transformed=False): + transformed = transformed or bool(element.get("transform")) + if element.get("data-native-math") is not None: + ref.require(element.tag == "{" + SVG + "}g" and element.get("data-native-math") == "1", + "native math marker must be a version-1 group") + ref.require(not transformed, "native math markers do not support ancestor/group transforms") + nodes = element.findall("{" + SVG + "}metadata") + ref.require(len(nodes) == 1 and nodes[0].get("type") == "application/json", "math requires one JSON metadata node") + spec = json.loads(nodes[0].text or "") + _validate_spec(spec, canvas) + ref.require(element.get("id") == spec["id"] and spec["id"] not in ids, "math ID mismatch or duplicate") + visible = "".join(n.text or "" for n in element.iter() if n.tag in ("{" + SVG + "}text", "{" + SVG + "}tspan")) + ref.require(visible == linear_text(spec["expression"]), "math fallback differs from structured expression; regenerate") + ids.add(spec["id"]) + token = TOKEN_PREFIX + hashlib.sha256(spec["id"].encode()).hexdigest()[:24] + for child in list(element): + element.remove(child) + element.attrib.pop("data-native-math") + x, y, _, _ = spec["bounds"] + placeholder = SVG_ET.SubElement(element, "{" + SVG + "}text", { + "x": str(x), "y": str(y + spec["font_size_pt"] * canvas["pt_to_px"]), + "font-family": spec["font_family"], "font-size": str(spec["font_size_pt"] * canvas["pt_to_px"]), + "fill": spec["color"]}) + placeholder.text = token + specs.append((token, spec)) + return + for child in element: + walk(child, transformed) + + walk(root) + return specs + + +def _sub(parent, namespace, local, **attrs): + return ET.SubElement(parent, "{" + namespace + "}" + local, **attrs) + + +def _property(parent, name, value): + return _sub(parent, M, name, **{"{" + M + "}val": str(value)}) + + +def _run_properties(parent, spec): + properties = _sub(parent, A, "rPr", sz=str(round(spec["font_size_pt"] * 100)), b="0", dirty="0") + _sub(_sub(properties, A, "solidFill"), A, "srgbClr", val=spec["color"][1:]) + for script in ("latin", "ea", "cs"): + _sub(properties, A, script, typeface=spec["font_family"]) + return properties + + +def _control(parent, spec): + _run_properties(_sub(parent, M, "ctrlPr"), spec) + + +def _expression(parent, expr, spec): + if isinstance(expr, str) or expr["type"] == "text": + run = _sub(parent, M, "r") + if isinstance(expr, dict): + properties = _sub(run, M, "rPr") + _property(properties, "sty", {"plain": "p", "italic": "i", "bold": "b", "bold-italic": "bi"}[expr.get("style", "plain")]) + _run_properties(run, spec) + text = _sub(run, M, "t") + text.set("{http://www.w3.org/XML/1998/namespace}space", "preserve") + text.text = expr if isinstance(expr, str) else expr["text"] + return + kind = expr["type"] + if kind == "row": + for value in expr["items"]: + _expression(parent, value, spec) + return + if kind == "fraction": + node = _sub(parent, M, "f") + _control(_sub(node, M, "fPr"), spec) + _expression(_sub(node, M, "num"), expr["numerator"], spec) + _expression(_sub(node, M, "den"), expr["denominator"], spec) + elif kind == "script": + tag = "sSubSup" if "sub" in expr and "sup" in expr else "sSub" if "sub" in expr else "sSup" + node = _sub(parent, M, tag) + _control(_sub(node, M, tag + "Pr"), spec) + _expression(_sub(node, M, "e"), expr["base"], spec) + for key in ("sub", "sup"): + if key in expr: + _expression(_sub(node, M, key), expr[key], spec) + elif kind == "radical": + node = _sub(parent, M, "rad") + props = _sub(node, M, "radPr") + _property(props, "degHide", "0" if "degree" in expr else "1") + _control(props, spec) + degree = _sub(node, M, "deg") + if "degree" in expr: + _expression(degree, expr["degree"], spec) + _expression(_sub(node, M, "e"), expr["body"], spec) + elif kind == "delimiter": + node = _sub(parent, M, "d") + props = _sub(node, M, "dPr") + _property(props, "begChr", expr["open"]) + _property(props, "endChr", expr["close"]) + _control(props, spec) + _expression(_sub(node, M, "e"), expr["body"], spec) + elif kind == "nary": + node = _sub(parent, M, "nary") + props = _sub(node, M, "naryPr") + _property(props, "chr", expr["operator"]) + _property(props, "limLoc", "undOvr" if spec["display"] == "block" else "subSup") + _property(props, "subHide", "0" if "lower" in expr else "1") + _property(props, "supHide", "0" if "upper" in expr else "1") + _control(props, spec) + for source, tag in (("lower", "sub"), ("upper", "sup")): + child = _sub(node, M, tag) + if source in expr: + _expression(child, expr[source], spec) + _expression(_sub(node, M, "e"), expr["body"], spec) + else: + node = _sub(parent, M, "m") + props = _sub(node, M, "mPr") + columns = _sub(props, M, "mcs") + column = _sub(_sub(columns, M, "mc"), M, "mcPr") + _property(column, "count", len(expr["rows"][0])) + _control(props, spec) + for row in expr["rows"]: + native_row = _sub(node, M, "mr") + for value in row: + _expression(_sub(native_row, M, "e"), value, spec) + + +def _equation_shape(original, spec, px_to_emu): + shape = copy.deepcopy(original) + identity = shape.find("p:nvSpPr/p:cNvPr", NS) + identity.set("name", spec["id"]) + identity.set("descr", linear_text(spec["expression"])) + transform = shape.find("p:spPr/a:xfrm", NS) + ref.require(transform is not None, "math placeholder has no native geometry") + off, extent = transform.find("a:off", NS), transform.find("a:ext", NS) + x, y, w, h = spec["bounds"] + off.set("x", str(round(x * px_to_emu))); off.set("y", str(round(y * px_to_emu))) + extent.set("cx", str(round(w * px_to_emu))); extent.set("cy", str(round(h * px_to_emu))) + old_body = shape.find("p:txBody", NS) + shape.remove(old_body) + body = _sub(shape, P, "txBody") + body_props = _sub(body, A, "bodyPr", wrap="none", anchor="ctr", lIns="0", rIns="0", tIns="0", bIns="0") + _sub(body_props, A, "noAutofit") + _sub(body, A, "lstStyle") + paragraph = _sub(body, A, "p") + _sub(paragraph, A, "pPr", algn={"left": "l", "center": "ctr", "right": "r"}[spec["align"]]) + math_node = _sub(paragraph, A14, "m") + if spec["display"] == "block": + container = _sub(math_node, M, "oMathPara") + _property(_sub(container, M, "oMathParaPr"), "jc", spec["align"]) + else: + container = math_node + _expression(_sub(container, M, "oMath"), spec["expression"], spec) + return shape + + +def apply_math(path: Path, pages, canvas): + """Install explicit Office Math choices into the temporary native package.""" + if not any(pages): + return {"native_math_objects": 0} + scale = canvas["ppt_inches"][0] * 914400 / canvas["width"] + result = [] + with zipfile.ZipFile(path) as archive: + entries = [(info, archive.read(info.filename)) for info in archive.infolist()] + changes = {} + for index, specs in enumerate(pages, 1): + if not specs: + continue + part = f"ppt/slides/slide{index}.xml" + raw = next(value for info, value in entries if info.filename == part) + tree = ET.fromstring(raw) + for token, spec in specs: + matches = [sp for sp in tree.findall(".//p:sp", NS) + if "".join(sp.itertext()).find(token) >= 0 + and "".join(sp.xpath(".//a:t/text()", namespaces=NS)) == token] + ref.require(len(matches) == 1, f"math compiler token missing/ambiguous: {spec['id']}") + original = matches[0] + for ancestor in original.iterancestors("{" + P + "}grpSp"): + transform = ancestor.find("p:grpSpPr/a:xfrm", NS) + ref.require(transform is not None, "math parent group lacks geometry") + ref.require(transform.find("a:off", NS).attrib == transform.find("a:chOff", NS).attrib + and transform.find("a:ext", NS).attrib == transform.find("a:chExt", NS).attrib, + "math parent group coordinate transform is unsupported") + native = _equation_shape(original, spec, scale) + fallback = copy.deepcopy(native) + body = fallback.find("p:txBody", NS) + paragraph = body.find("a:p", NS) + paragraph.remove(paragraph.find("a14:m", NS)) + run = _sub(paragraph, A, "r") + _run_properties(run, spec) + _sub(run, A, "t").text = linear_text(spec["expression"]) + alternative = ET.Element("{" + MC + "}AlternateContent", nsmap={"mc": MC}) + choice = ET.SubElement(alternative, "{" + MC + "}Choice", Requires="a14", nsmap={"a14": A14, "m": M}) + choice.append(native) + _sub(alternative, MC, "Fallback").append(fallback) + original.getparent().replace(original, alternative) + result.append({"slide": index, "id": spec["id"], "font_family": spec["font_family"], + "display": spec["display"], "object_type": "Office Math", + "fallback": "linear ordinary text for non-math-capable applications"}) + serialized = ET.tostring(tree, xml_declaration=True, encoding="utf-8", standalone=True) + ref.require(TOKEN_PREFIX.encode() not in serialized, "temporary math token remains in output") + changes[part] = serialized + with tempfile.NamedTemporaryFile(prefix="office-math-", suffix=".pptx", dir=path.parent, delete=False) as temporary: + target = Path(temporary.name) + try: + with zipfile.ZipFile(target, "w", zipfile.ZIP_DEFLATED) as archive: + for info, value in entries: + archive.writestr(info, changes.get(info.filename, value)) + target.replace(path) + finally: + target.unlink(missing_ok=True) + return {"native_math_objects": len(result), "math_objects": result, + "math_verification_scope": "native Office Math structure; application rendering/editing require separate checks"} diff --git a/scripts/native_export.py b/scripts/native_export.py index 996d451..6992a7d 100644 --- a/scripts/native_export.py +++ b/scripts/native_export.py @@ -141,7 +141,8 @@ def export(manifest_path: Path, theme_id: str, sources: list[Path], output: Path root = prepare_svg(source, target, template_root) ref.require(root.get("data-pptx-layout") == layout_id, f"Roster/source layout identity mismatch: {layout_id}") definitions.append(target) - public, expected_names, compiled_notes, indexed_notes = [], [], {}, {} + from math_component import prepare_math, apply_math + public, expected_names, compiled_notes, indexed_notes, math_pages = [], [], {}, {}, [] for index, source in enumerate(sources): target = temporary / f"page_{index:04d}.svg" allowed = source_root or (template_root if source.is_relative_to(template_root) else source.parent) @@ -149,6 +150,10 @@ def export(manifest_path: Path, theme_id: str, sources: list[Path], output: Path layout_id = root.get("data-pptx-layout") ref.require(layout_id in layouts, f"Source references an undeclared layout: {layout_id}") ref.require(root.get("viewBox") == style["canvas"]["viewbox"], "Source viewBox differs from declared template canvas") + equations = prepare_math(root, style["canvas"]) + if equations: + ET.ElementTree(root).write(target, encoding="utf-8", xml_declaration=True) + math_pages.append(equations) public.append(target) expected_names.append(layouts[layout_id]) if source.stem in notes: @@ -166,6 +171,7 @@ def export(manifest_path: Path, theme_id: str, sources: list[Path], output: Path workers=1, primary_language=style.get("language")) ref.require(success, "Bundled structured compiler failed") finalize(generated, theme, style) + math_facts = apply_math(generated, math_pages, style["canvas"]) facts = verify(generated, layouts, expected_names, indexed_notes) # Exclusive publication also protects against a writer racing the initial # existence check. A failed compile never disturbs the previous output. @@ -179,7 +185,7 @@ def export(manifest_path: Path, theme_id: str, sources: list[Path], output: Path if created: output.unlink(missing_ok=True) raise - return {**facts, "template_id": manifest["template_id"], "template_version": manifest["version"], + return {**facts, **math_facts, "template_id": manifest["template_id"], "template_version": manifest["version"], "theme": theme_id, "output": str(output)} diff --git a/scripts/paragraph_layout.py b/scripts/paragraph_layout.py new file mode 100644 index 0000000..a066914 --- /dev/null +++ b/scripts/paragraph_layout.py @@ -0,0 +1,192 @@ +"""Conservative line breaking for a single ordinary horizontal paragraph. + +Supports CJK with basic Latin words, common ASCII number/date/version tokens, +paired straight quotes and common Chinese punctuation. It preserves every input +character, including spaces. This is not a full Unicode line-break/grapheme, +vertical composition, punctuation-compression or hanging-punctuation engine. +Short labels and table values are not paragraphs and should not use this helper. + +The caller supplies deterministic, nonnegative finite text advance measurements +in the same units as max_width. No font library is imported. Width monotonicity +is not assumed. A boundary DAG is solved once, in polynomial time; measurements +of repeated substrings are cached within this call with bounded cache storage. + +Common punctuation rules follow W3C CLREQ 6.1.1 and 6.1.2. The additional ban on +one-content-character lines applies to EVERY line, beyond CLREQ 7.1.2's usual +last-line orphan treatment, as required by this workflow. +""" +from __future__ import annotations + +from functools import lru_cache +import math +import re +from typing import Callable +import unicodedata + + +_OPEN = frozenset("([{([{〈《「『【〔〖〘〚‘“«‹﹁﹃﹙﹛﹝") +_CLOSE = frozenset(")]})]}〉》」』】〕〗〙〛’”»›﹂﹄﹚﹜﹞") +_PAUSE = frozenset("、。,.,.;:!?;:?!。、·・…—%%‰") +_SENTENCE = frozenset("。.!!??") +_CLAUSE = frozenset(",,;;::、") +# Western words include ordinary contractions, hyphenated identifiers and +# versions; numbers include grouping, decimals, dates/times, exponents and units. +_TOKEN = re.compile( + r"[A-Za-z][A-Za-z0-9_]*(?:['’._/\-][A-Za-z0-9_]+)*" + r"|[+\-±$¥¥€£]?(?:[0-9]{1,3}(?:,[0-9]{3})+|[0-9]+)" + r"(?:\.[0-9]+)*(?:[/:\-][0-9]+)*(?:[eE][+\-]?[0-9]+)?" + r"(?:[%%‰℃℉°₫]|[A-Za-z]+)?" + r"|……|——" +) + + +def _paragraph_input(text: str) -> None: + if not isinstance(text, str) or not text: + raise ValueError("text must be a nonempty semantic paragraph") + if any(c in "\r\n\u2028\u2029" or unicodedata.category(c) in ("Cc", "Cf") for c in text): + raise ValueError("paragraph must not contain line breaks, control or format characters") + + +def _content(char: str) -> bool: + return not char.isspace() and unicodedata.category(char)[0] not in "PM" + + +def _boundary_data(text: str): + """Cache character classes and legal token boundaries for the whole text.""" + count = len(text) + boundaries = [True] * (count + 1) + for match in _TOKEN.finditer(text): + for i in range(match.start() + 1, match.end()): + boundaries[i] = False + opens, closes = [], [] + straight_open = {"'": False, '"': False} + content = [0] + for i, char in enumerate(text): + category = unicodedata.category(char) + opening = char in _OPEN or category in ("Ps", "Pi") + closing = char in _CLOSE or char in _PAUSE or category in ("Pe", "Pf") + if char in straight_open: + # An apostrophe within a protected Western word is not a quote. + if char != "'" or boundaries[i] or boundaries[i + 1]: + opening = not straight_open[char] + closing = straight_open[char] + straight_open[char] = not straight_open[char] + opens.append(opening) + closes.append(closing) + content.append(content[-1] + int(_content(char))) + if category.startswith("M"): + boundaries[i] = False + first, last = [count] * (count + 1), [-1] * (count + 1) + for i in range(count - 1, -1, -1): + first[i] = first[i + 1] if text[i].isspace() else i + for end in range(1, count + 1): + last[end] = last[end - 1] if text[end - 1].isspace() else end - 1 + return boundaries, opens, closes, content, first, last + + +def validate_lines(lines: list[str]) -> list[dict]: + """Return line-numbered rule violations; widths need the caller's measure. + + The full concatenated paragraph supplies quote/token context. No content or + whitespace is changed. An empty list reports an empty paragraph; malformed + API arguments raise ValueError rather than returning a misleading success. + """ + if not isinstance(lines, list) or any(not isinstance(line, str) for line in lines): + raise ValueError("lines must be a list of strings") + if not lines: + return [{"line": 1, "code": "empty_paragraph", "message": "paragraph has no lines"}] + text = "".join(lines) + boundaries, opens, closes, content, first, last = _boundary_data(text) + issues, start = [], 0 + for number, line in enumerate(lines, 1): + end = start + len(line) + def issue(code, message): + issues.append({"line": number, "code": code, "message": message}) + if not line or line.isspace(): + issue("empty_line", "paragraph line must contain text") + else: + if any(c in "\r\n\u2028\u2029" or unicodedata.category(c) in ("Cc", "Cf") for c in line): + issue("unsupported_control", "line contains a line break, control or format character") + if closes[first[start]]: + issue("prohibited_line_start", "line starts with closing or pause punctuation") + if opens[last[end]]: + issue("prohibited_line_end", "line ends with opening punctuation") + amount = content[end] - content[start] + if amount == 0: + issue("punctuation_only", "paragraph line contains only punctuation and whitespace") + elif amount == 1: + issue("single_content_character", "paragraph line contains only one content character") + if end < len(text) and not boundaries[end]: + issue("split_token", "line break splits a protected word, number, paired mark or combining sequence") + start = end + return issues + + +def wrap_paragraph(text: str, max_width: float, measure: Callable[[str], float], *, + max_lines: int | None = None) -> list[str]: + """Reflow the complete paragraph without changing content or font size. + + Among valid layouts choose the fewest lines, then prefer sentence/clause + boundaries, then reduce raggedness. Dynamic programming can move earlier + breaks to avoid an orphan anywhere, rather than patching the final line. + At most O(b²) segment measurements are considered for b legal boundaries; + no exponential recursion and no assumption of additive/monotone font width. + """ + _paragraph_input(text) + if type(max_width) not in (int, float) or not math.isfinite(max_width) or max_width <= 0: + raise ValueError("max_width must be a positive finite number") + if not callable(measure): + raise ValueError("measure must be callable") + if max_lines is not None and (type(max_lines) is not int or max_lines < 1): + raise ValueError("max_lines must be a positive integer or None") + boundaries, opens, closes, content, first, last = _boundary_data(text) + if content[-1] < 2: + raise ValueError("paragraph requires at least two content characters; short labels are outside this helper") + points = [i for i, allowed in enumerate(boundaries) if allowed] + count = len(points) + + @lru_cache(maxsize=4096) + def width(value): + result = measure(value) + if type(result) not in (int, float) or not math.isfinite(result) or result < 0: + raise ValueError("measure must return a nonnegative finite number") + return result + + # Score line endings once; closing quotation/bracket marks can follow the + # actual sentence punctuation without losing its preferred break status. + preferences = [] + for end in points: + index = last[end] + while index >= 0 and (text[index] in _CLOSE or text[index] in "\"'" and closes[index]): + index -= 1 + char = text[index] if index >= 0 else "" + preferences.append(0 if char in _SENTENCE else 1 if char in _CLAUSE + else 2 if end and text[end - 1].isspace() else 3) + best, following = [None] * count, [None] * count + best[-1] = (0, 0, 0.0) + for i in range(count - 2, -1, -1): + start = points[i] + if first[start] == len(text) or closes[first[start]]: + continue + for j in range(i + 1, count): + end = points[j] + if best[j] is None or content[end] - content[start] < 2 or opens[last[end]]: + continue + measured = width(text[start:end]) + if measured > max_width: + continue + tail = best[j] + score = (tail[0] + 1, tail[1] + (preferences[j] if j < count - 1 else 0), + tail[2] + ((max_width - measured) / max_width) ** 2) + if best[i] is None or score < best[i]: + best[i], following[i] = score, j + if best[0] is None: + raise ValueError("paragraph cannot fit width while preserving punctuation, tokens and at least two content characters per line; enlarge bounds, rephrase or split the paragraph") + if max_lines is not None and best[0][0] > max_lines: + raise ValueError(f"paragraph requires {best[0][0]} valid lines, exceeding max_lines={max_lines}; enlarge bounds, rephrase or split the paragraph") + lines, current = [], 0 + while current < count - 1: + next_index = following[current] + lines.append(text[points[current]:points[next_index]]) + current = next_index + return lines diff --git a/scripts/prepare_native_project.py b/scripts/prepare_native_project.py index 4555ae1..c1e7a16 100644 --- a/scripts/prepare_native_project.py +++ b/scripts/prepare_native_project.py @@ -51,7 +51,8 @@ def prepare(root: Path): staging.mkdir() shutil.copytree(skill / "runtime", staging / "runtime", ignore=shutil.ignore_patterns("__pycache__", "*.pyc", "*.pyo")) (staging / "scripts").mkdir() - for name in ("native_export.py", "finalize_theme.py", "template_ref.py"): + for name in ("native_export.py", "finalize_theme.py", "template_ref.py", "math_component.py", + "component_typography.py", "paragraph_layout.py", "text_block.py", "layout_spacing.py"): shutil.copyfile(skill / "scripts" / name, staging / "scripts" / name) # The snapshot is an explicit build input; no installed Skill is needed # after this project is moved to a different directory or machine. diff --git a/scripts/table_component.py b/scripts/table_component.py index 1fd5872..9021532 100644 --- a/scripts/table_component.py +++ b/scripts/table_component.py @@ -12,6 +12,7 @@ import unicodedata import xml.etree.ElementTree as ET import template_ref as ref +from component_typography import resolve_role SVG = 'http://www.w3.org/2000/svg' @@ -48,9 +49,9 @@ def _node(parent, tag, **attributes): return ET.SubElement(parent, '{'+SVG+'}'+tag, {key.replace('_', '-'):str(value) for key,value in attributes.items()}) -def _label(parent, value, x, y, font, size, color, align): +def _label(parent, value, x, y, font, size, color, align, weight='normal'): result = _node(parent, 'text', x=x, y=y, font_family=font, font_size=size, - font_weight='normal', fill=color, text_anchor='end' if align == 'r' else 'middle' if align == 'ctr' else 'start') + font_weight=weight, fill=color, text_anchor='end' if align == 'r' else 'middle' if align == 'ctr' else 'start') result.text = value @@ -143,12 +144,10 @@ def render_grouped_table(raw: dict, style: dict, theme: dict, bounds: tuple, ref.require(all(row[status['column']] in status['values'] for row in rows), 'unknown status value') cfg = style.get('components',{}).get('table_grouped',{}) _fields(cfg, {'group_height_px','header_height_px','row_height_px','cell_padding_px'}, set(), 'style.components.table_grouped') - typography = style['typography']; roles = typography['roles']; families = typography['families'] - font = families[roles['label']['family_role']]['family'] - caption_font = families[roles['caption']['family_role']]['family'] - size = _number(roles['label']['size_pt'],'label size')*_number(canvas['pt_to_px'],'pt_to_px') - small = _number(roles['caption']['size_pt'],'caption size')*canvas['pt_to_px'] - ref.require(size > 0 and small > 0, 'font sizes must be positive') + label_role = resolve_role(style, style.get('components',{}).get('table',{}).get('text_role','label')) + caption_role = resolve_role(style, 'caption') + font, caption_font = label_role.family, caption_role.family + size, small = label_role.size, caption_role.size padding = cfg.get('cell_padding_px',[8,16]) ref.require(isinstance(padding,list) and len(padding) == 2 and all(_number(v,'cell padding') >= 0 and v % 8 == 0 for v in padding), 'cell_padding_px requires two nonnegative 8px multiples') top, side = padding @@ -179,11 +178,11 @@ def render_grouped_table(raw: dict, style: dict, theme: dict, bounds: tuple, start,span = group['start'],group['span']; gx=x+sum(widths[:start]); gw=sum(widths[start:start+span]) fill = palette['accent1' if i%2 == 0 else 'accent2'] _fit(group['label'],gw-2*side,small,'group header') - cell = {'paragraphs':[{'align':'ctr','runs':[{'text':group['label'],'font_size':small,'font_family':caption_font,'bold':False,'color':palette['lt1']}]}],'fill':fill,'color':palette['lt1'],'font_size':small,'bold':False,'align':'ctr','valign':'middle','padding':{'top':0,'bottom':0,'left':side,'right':side},'borders':none()} + cell = {'paragraphs':[{'align':'ctr','runs':[{'text':group['label'],'font_size':small,'font_family':caption_font,'bold':caption_role.bold,'color':palette['lt1']}]}],'fill':fill,'color':palette['lt1'],'font_size':small,'bold':caption_role.bold,'align':'ctr','valign':'middle','padding':{'top':0,'bottom':0,'left':side,'right':side},'borders':none()} if span > 1: cell['col_span']=span table_rows[start]=cell _node(fallback,'rect',x=gx,y=y,width=gw,height=gh,fill=fill) - _label(fallback,group['label'],gx+gw/2,y+(gh+small*.72)/2,caption_font,small,palette['lt1'],'ctr') + _label(fallback,group['label'],gx+gw/2,y+(gh+small*.72)/2,caption_font,small,palette['lt1'],'ctr',caption_role.svg_weight) table_rows = [table_rows] group_ends = {g['start']+g['span']-1 for g in groups[:-1]} cy = y+gh @@ -203,24 +202,24 @@ def render_grouped_table(raw: dict, style: dict, theme: dict, bounds: tuple, if focused: borders['top']={'style':'solid','color':palette['accent1'],'width':1} if ci == 0: borders['left']={'style':'solid','color':palette['accent1'],'width':4} - cell={'fill':fill,'color':color,'align':align,'font_size':size,'bold':False,'padding':pad,'valign':'middle','borders':borders} + cell={'fill':fill,'color':color,'align':align,'font_family':font,'font_size':size,'bold':label_role.bold,'padding':pad,'valign':'middle','borders':borders} _node(fallback,'rect',x=cx,y=cy,width=widths[ci],height=cell_height,fill=fill) tx=cx+widths[ci]-side if align == 'r' else cx+pad['left'] if header: title,unit=value['label'],value.get('unit','') _fit(title,widths[ci]-2*side,size,'column header') - cell['paragraphs']=[{'align':align,'runs':[{'text':title,'font_size':size,'font_family':font,'bold':False,'color':color}]}] + cell['paragraphs']=[{'align':align,'runs':[{'text':title,'font_size':size,'font_family':font,'bold':label_role.bold,'color':color}]}] if unit: _fit(unit,widths[ci]-2*side,small,'column unit') - cell['paragraphs'].append({'align':align,'runs':[{'text':unit,'font_size':small,'font_family':caption_font,'bold':False,'color':palette['dk2']}]}) + cell['paragraphs'].append({'align':align,'runs':[{'text':unit,'font_size':small,'font_family':caption_font,'bold':caption_role.bold,'color':palette['dk2']}]}) baseline=cy+(cell_height-size-small-8)/2+size*.8 - _label(fallback,title,tx,baseline,font,size,color,align) - _label(fallback,unit,tx,baseline+small+8,caption_font,small,palette['dk2'],align) - else: _label(fallback,title,tx,cy+(cell_height+size*.72)/2,font,size,color,align) + _label(fallback,title,tx,baseline,font,size,color,align,label_role.svg_weight) + _label(fallback,unit,tx,baseline+small+8,caption_font,small,palette['dk2'],align,caption_role.svg_weight) + else: _label(fallback,title,tx,cy+(cell_height+size*.72)/2,font,size,color,align,label_role.svg_weight) else: displayed=_formatted(value,columns[ci]); cell['text']=displayed _fit(displayed,widths[ci]-pad['left']-side,size,'table cell') - _label(fallback,displayed,tx,cy+(cell_height+size*.72-(8 if ci in bar_columns else 0))/2,font,size,color,align) + _label(fallback,displayed,tx,cy+(cell_height+size*.72-(8 if ci in bar_columns else 0))/2,font,size,color,align,label_role.svg_weight) if ci in bar_columns: maximum=max(r[ci] for r in rows) or 1; track=widths[ci]-2*side marks.append({'id':f'{component_id}-bar-r{ri}-c{ci}','kind':'bar','x':cx+side,'y':cy+cell_height-12,'width':track,'length':track*(value/maximum),'value':value,'domain':[0,maximum],'column':ci,'row':ri-1,'focused':focused}) diff --git a/scripts/text_block.py b/scripts/text_block.py new file mode 100644 index 0000000..00c4fe9 --- /dev/null +++ b/scripts/text_block.py @@ -0,0 +1,134 @@ +"""Render complete semantic paragraphs with template-owned spacing. + +Each paragraph produces one SVG text node containing derived tspan lines, so the +default native exporter can keep one text frame per paragraph. The caller owns +actual-font measurements; content cannot supply per-line or per-paragraph +coordinates. Metadata retains the full input and derived layout for inspection. +""" +from __future__ import annotations + +import json +import math +from typing import Callable +import xml.etree.ElementTree as ET + +from component_typography import resolve_role +from layout_spacing import check_spacing, resolve_spacing +from paragraph_layout import wrap_paragraph +import template_ref as ref + + +SVG = "http://www.w3.org/2000/svg" +ET.register_namespace("", SVG) + + +def _number(value, label, *, positive=False): + if (type(value) not in (int, float) or not math.isfinite(value) + or value < 0 or positive and value == 0): + raise ValueError(f"{label}: expected {'positive' if positive else 'nonnegative'} finite number") + return float(value) + + +def render_text_block(data: dict, style: dict, theme: dict, bounds: tuple, + component_id: str, measure: Callable[[str], float]) -> ET.Element: + """Render at declared size; reject capacity failures without clipping text. + + data = {schema_version:1, paragraphs:[complete string,...], + text_role?:body, palette_index?:dk1}. + ``measure(text)`` returns pixel advance at the resolved role's actual font + and size. Fixed text_padding/paragraph roles resolve template spacing, and + the typography role supplies line height. A paragraph + occupies one line-height slot per line; gap is extra space between slots of + successive paragraphs. Bounds and line records describe these layout slots, + not exact font ink extents. + """ + if (not isinstance(data, dict) or type(data.get("schema_version")) is not int + or data["schema_version"] != 1): + raise ValueError("text block data requires schema_version 1") + if set(data) - {"schema_version", "paragraphs", "text_role", "palette_index"}: + raise ValueError("text block only accepts complete paragraphs and declared text/palette roles") + paragraphs = data.get("paragraphs") + if not isinstance(paragraphs, list) or not paragraphs or any(not isinstance(p, str) for p in paragraphs): + raise ValueError("paragraphs must be a nonempty list of complete strings") + if not callable(measure): + raise ValueError("measure must be callable") + ref.identifier(component_id, "component ID") + if not isinstance(bounds, (list, tuple)) or len(bounds) != 4: + raise ValueError("bounds requires [x,y,width,height]") + x, y = (_number(value, "bounds position") for value in bounds[:2]) + width, height = (_number(value, "bounds size", positive=True) for value in bounds[2:]) + canvas = style["canvas"] + if x + width > canvas["width"] or y + height > canvas["height"]: + raise ValueError("text block bounds outside canvas") + cfg = style["components"]["text_group"] + padding = resolve_spacing(style, "text_padding") + gap = resolve_spacing(style, "paragraph") + if not isinstance(padding, tuple) or not isinstance(gap, float): + raise ValueError("text_padding must be a padding role and paragraph must be a gap role") + top, right, bottom, left = padding + if gap <= 0: + raise ValueError("paragraph spacing must be positive to distinguish paragraphs") + if cfg.get("alignment", "left") != "left": + raise ValueError("text block currently supports left alignment only") + role_name = data.get("text_role", cfg.get("text_role", "body")) + if not isinstance(role_name, str): + raise ValueError("text_role requires a declared role name") + role = resolve_role(style, role_name) + palette_name = data.get("palette_index", "dk1") + if not isinstance(palette_name, str) or palette_name not in theme["palette"]: + raise ValueError("palette_index requires a declared theme palette entry") + inner_width, inner_height = width - left - right, height - top - bottom + if inner_width <= 0 or inner_height <= 0: + raise ValueError("padding leaves no text capacity") + rows = [wrap_paragraph(paragraph, inner_width, measure) for paragraph in paragraphs] + used_height = sum(len(lines) * role.line_size for lines in rows) + gap * (len(rows) - 1) + if used_height > inner_height: + raise ValueError(f"text block requires {used_height:g}px height plus padding, exceeding {inner_height:g}px capacity; enlarge bounds, rephrase or change layout") + group = ET.Element(f"{{{SVG}}}g", {"id": component_id, "data-component-kind": "text-block"}) + metadata = ET.SubElement(group, f"{{{SVG}}}metadata", {"type": "application/json"}) + records, current_y = [], y + top + font_style = {"family": role.family, "size_px": role.size, "weight": role.weight, + "line_height": role.line_height, "line_step_px": role.line_size, + "text_role": role_name, "palette_index": palette_name} + for index, (paragraph, lines) in enumerate(zip(paragraphs, rows), 1): + block_height = len(lines) * role.line_size + # Center nominal font height in each line slot; baseline is shared by + # all instances. Actual glyph extents still need renderer verification. + baseline = current_y + (role.line_size - role.size) / 2 + role.size * .8 + text_id = f"{component_id}-paragraph-{index}" + text = ET.SubElement(group, f"{{{SVG}}}text", { + "id": text_id, "x": str(x + left), "y": str(baseline), + "font-family": role.family, "font-size": str(role.size), + "font-weight": role.svg_weight, "fill": theme["palette"][palette_name], + "data-logical-block": text_id, "{http://www.w3.org/XML/1998/namespace}space": "preserve"}) + line_records = [] + for line_index, line in enumerate(lines): + ET.SubElement(text, f"{{{SVG}}}tspan", { + "x": str(x + left), "dy": "0" if line_index == 0 else str(role.line_size)}).text = line + line_records.append({"text": line, "baseline_y": baseline + line_index * role.line_size}) + records.append({"id": text_id, "text": paragraph, "lines": line_records, + "bounds": [x + left, current_y, inner_width, block_height], + "native_text_frame_expected": 1}) + current_y += block_height + gap + inner_bounds = [x + left, y + top, inner_width, inner_height] + inner_id = f"{component_id}-inner" + spacing_boxes = {component_id: [x, y, width, height], inner_id: inner_bounds, + **{record["id"]: record["bounds"] for record in records}} + spacing_checks = [{"id": f"{component_id}-text-padding", "kind": "padding", + "outer": component_id, "inner": inner_id, "role": "text_padding"}] + spacing_checks.extend({"id": f"{component_id}-paragraph-gap-{index}", "kind": "gap", + "a": records[index - 1]["id"], "b": records[index]["id"], + "axis": "y", "role": "paragraph"} for index in range(1, len(records))) + spacing_issues = check_spacing(spacing_boxes, spacing_checks, style=style) + if spacing_issues: + raise ValueError(f"derived text block violates fixed spacing: {spacing_issues}") + metadata.text = json.dumps({"schema_version": 1, "component_id": component_id, + "data": data, "bounds": [x, y, width, height], + "content_bounds": [x + left, y + top, inner_width, used_height], + "inner_bounds": inner_bounds, + "padding_px": [top, right, bottom, left], + "paragraph_gap_px": gap, "font_style": font_style, + "paragraphs": records, + "spacing_contract": {"policy": "fixed", "boxes": spacing_boxes, + "checks": spacing_checks, "violations": spacing_issues}}, ensure_ascii=False) + return group