From 8e174df3d5d3998c898b6ea1232b032f80087adb Mon Sep 17 00:00:00 2001 From: qixinbo Date: Tue, 31 Mar 2026 22:47:10 +0800 Subject: [PATCH] feat: add minimax office suite --- .../skills_builtin/minimax-docx/.gitignore | 3 + .../app/skills_builtin/minimax-docx/LICENSE | 21 + .../app/skills_builtin/minimax-docx/SKILL.md | 274 ++ .../assets/styles/academic_styles.xml | 250 + .../assets/styles/corporate_styles.xml | 284 ++ .../assets/styles/default_styles.xml | 449 ++ .../assets/xsd/aesthetic-rules.xsd | 470 ++ .../assets/xsd/business-rules.xsd | 130 + .../minimax-docx/assets/xsd/common-types.xsd | 159 + .../minimax-docx/assets/xsd/wml-subset.xsd | 589 +++ .../minimax-docx/references/cjk_typography.md | 357 ++ .../cjk_university_template_guide.md | 184 + .../minimax-docx/references/comments_guide.md | 191 + .../references/design_good_bad_examples.md | 829 ++++ .../references/design_principles.md | 819 ++++ .../references/openxml_element_order.md | 308 ++ .../references/openxml_encyclopedia_part1.md | 4061 +++++++++++++++++ .../references/openxml_encyclopedia_part2.md | 2820 ++++++++++++ .../references/openxml_encyclopedia_part3.md | 3381 ++++++++++++++ .../references/openxml_namespaces.md | 82 + .../minimax-docx/references/openxml_units.md | 72 + .../references/scenario_a_create.md | 284 ++ .../references/scenario_b_edit_content.md | 295 ++ .../references/scenario_c_apply_template.md | 456 ++ .../references/track_changes_guide.md | 200 + .../references/troubleshooting.md | 506 ++ .../references/typography_guide.md | 294 ++ .../references/xsd_validation_guide.md | 158 + .../minimax-docx/scripts/doc_to_docx.sh | 40 + .../minimax-docx/scripts/docx_preview.sh | 37 + .../MiniMaxAIDocx.Cli.csproj | 19 + .../dotnet/MiniMaxAIDocx.Cli/Program.cs | 18 + .../Commands/AnalyzeCommand.cs | 147 + .../Commands/ApplyTemplateCommand.cs | 322 ++ .../Commands/CreateCommand.cs | 324 ++ .../Commands/DiffCommand.cs | 155 + .../Commands/EditContentCommand.cs | 487 ++ .../Commands/FixOrderCommand.cs | 108 + .../Commands/MergeRunsCommand.cs | 122 + .../Commands/ValidateCommand.cs | 107 + .../MiniMaxAIDocx.Core.csproj | 15 + .../OpenXml/CommentSynchronizer.cs | 169 + .../OpenXml/ElementOrder.cs | 80 + .../OpenXml/NamespaceConstants.cs | 42 + .../MiniMaxAIDocx.Core/OpenXml/RunMerger.cs | 81 + .../OpenXml/StyleAnalyzer.cs | 81 + .../OpenXml/TrackChangesHelper.cs | 99 + .../OpenXml/UnitConverter.cs | 23 + .../Samples/AestheticRecipeSamples.cs | 1832 ++++++++ .../Samples/AestheticRecipeSamples_Batch1.cs | 910 ++++ .../Samples/AestheticRecipeSamples_Batch2.cs | 999 ++++ .../Samples/AestheticRecipeSamples_Batch3.cs | 1048 +++++ .../Samples/AestheticRecipeSamples_Batch4.cs | 1038 +++++ .../Samples/CharacterFormattingSamples.cs | 1020 +++++ .../Samples/DocumentCreationSamples.cs | 1121 +++++ .../Samples/FieldAndTocSamples.cs | 624 +++ .../Samples/FootnoteAndCommentSamples.cs | 675 +++ .../Samples/HeaderFooterSamples.cs | 838 ++++ .../Samples/ImageSamples.cs | 917 ++++ .../Samples/ListAndNumberingSamples.cs | 826 ++++ .../Samples/ParagraphFormattingSamples.cs | 1199 +++++ .../Samples/StyleSystemSamples.cs | 1487 ++++++ .../Samples/TableSamples.cs | 1163 +++++ .../Samples/TrackChangesSamples.cs | 595 +++ .../Typography/CjkHelper.cs | 39 + .../Typography/FontDefaults.cs | 24 + .../Typography/PageSizes.cs | 20 + .../Validation/BusinessRuleValidator.cs | 224 + .../Validation/GateCheckValidator.cs | 148 + .../Validation/ValidationResult.cs | 23 + .../Validation/XsdValidator.cs | 69 + .../scripts/dotnet/MiniMaxAIDocx.slnx | 4 + .../minimax-docx/scripts/env_check.sh | 196 + .../minimax-docx/scripts/setup.ps1 | 274 ++ .../minimax-docx/scripts/setup.sh | 504 ++ .../app/skills_builtin/minimax-pdf/README.md | 222 + .../app/skills_builtin/minimax-pdf/SKILL.md | 192 + .../minimax-pdf/design/design.md | 381 ++ .../minimax-pdf/scripts/cover.py | 1579 +++++++ .../minimax-pdf/scripts/fill_inspect.py | 200 + .../minimax-pdf/scripts/fill_write.py | 242 + .../minimax-pdf/scripts/make.sh | 491 ++ .../minimax-pdf/scripts/merge.py | 112 + .../minimax-pdf/scripts/palette.py | 521 +++ .../minimax-pdf/scripts/reformat_parse.py | 374 ++ .../minimax-pdf/scripts/render_body.py | 1052 +++++ .../minimax-pdf/scripts/render_cover.js | 111 + .../app/skills_builtin/minimax-xlsx/SKILL.md | 138 + .../minimax-xlsx/references/create.md | 691 +++ .../minimax-xlsx/references/edit.md | 684 +++ .../minimax-xlsx/references/fix.md | 37 + .../minimax-xlsx/references/format.md | 768 ++++ .../references/ooxml-cheatsheet.md | 231 + .../minimax-xlsx/references/read-analyze.md | 97 + .../minimax-xlsx/references/validate.md | 772 ++++ .../minimax-xlsx/scripts/formula_check.py | 422 ++ .../scripts/libreoffice_recalc.py | 248 + .../scripts/shared_strings_builder.py | 163 + .../minimax-xlsx/scripts/style_audit.py | 575 +++ .../minimax-xlsx/scripts/xlsx_add_column.py | 395 ++ .../minimax-xlsx/scripts/xlsx_insert_row.py | 274 ++ .../minimax-xlsx/scripts/xlsx_pack.py | 87 + .../minimax-xlsx/scripts/xlsx_reader.py | 362 ++ .../minimax-xlsx/scripts/xlsx_shift_rows.py | 396 ++ .../minimax-xlsx/scripts/xlsx_unpack.py | 130 + .../minimal_xlsx/[Content_Types].xml | 9 + .../templates/minimal_xlsx/_rels/.rels | 6 + .../minimal_xlsx/xl/_rels/workbook.xml.rels | 19 + .../minimal_xlsx/xl/sharedStrings.xml | 33 + .../templates/minimal_xlsx/xl/styles.xml | 160 + .../templates/minimal_xlsx/xl/workbook.xml | 30 + .../minimal_xlsx/xl/worksheets/sheet1.xml | 70 + .../skills_builtin/pptx-generator/SKILL.md | 249 + .../references/design-system.md | 392 ++ .../pptx-generator/references/editing.md | 162 + .../pptx-generator/references/pitfalls.md | 112 + .../pptx-generator/references/pptxgenjs.md | 420 ++ .../pptx-generator/references/slide-types.md | 413 ++ 118 files changed, 52241 insertions(+) create mode 100644 backend/app/skills_builtin/minimax-docx/.gitignore create mode 100644 backend/app/skills_builtin/minimax-docx/LICENSE create mode 100644 backend/app/skills_builtin/minimax-docx/SKILL.md create mode 100644 backend/app/skills_builtin/minimax-docx/assets/styles/academic_styles.xml create mode 100644 backend/app/skills_builtin/minimax-docx/assets/styles/corporate_styles.xml create mode 100644 backend/app/skills_builtin/minimax-docx/assets/styles/default_styles.xml create mode 100644 backend/app/skills_builtin/minimax-docx/assets/xsd/aesthetic-rules.xsd create mode 100644 backend/app/skills_builtin/minimax-docx/assets/xsd/business-rules.xsd create mode 100644 backend/app/skills_builtin/minimax-docx/assets/xsd/common-types.xsd create mode 100644 backend/app/skills_builtin/minimax-docx/assets/xsd/wml-subset.xsd create mode 100644 backend/app/skills_builtin/minimax-docx/references/cjk_typography.md create mode 100644 backend/app/skills_builtin/minimax-docx/references/cjk_university_template_guide.md create mode 100644 backend/app/skills_builtin/minimax-docx/references/comments_guide.md create mode 100644 backend/app/skills_builtin/minimax-docx/references/design_good_bad_examples.md create mode 100644 backend/app/skills_builtin/minimax-docx/references/design_principles.md create mode 100644 backend/app/skills_builtin/minimax-docx/references/openxml_element_order.md create mode 100644 backend/app/skills_builtin/minimax-docx/references/openxml_encyclopedia_part1.md create mode 100644 backend/app/skills_builtin/minimax-docx/references/openxml_encyclopedia_part2.md create mode 100644 backend/app/skills_builtin/minimax-docx/references/openxml_encyclopedia_part3.md create mode 100644 backend/app/skills_builtin/minimax-docx/references/openxml_namespaces.md create mode 100644 backend/app/skills_builtin/minimax-docx/references/openxml_units.md create mode 100644 backend/app/skills_builtin/minimax-docx/references/scenario_a_create.md create mode 100644 backend/app/skills_builtin/minimax-docx/references/scenario_b_edit_content.md create mode 100644 backend/app/skills_builtin/minimax-docx/references/scenario_c_apply_template.md create mode 100644 backend/app/skills_builtin/minimax-docx/references/track_changes_guide.md create mode 100644 backend/app/skills_builtin/minimax-docx/references/troubleshooting.md create mode 100644 backend/app/skills_builtin/minimax-docx/references/typography_guide.md create mode 100644 backend/app/skills_builtin/minimax-docx/references/xsd_validation_guide.md create mode 100755 backend/app/skills_builtin/minimax-docx/scripts/doc_to_docx.sh create mode 100755 backend/app/skills_builtin/minimax-docx/scripts/docx_preview.sh create mode 100644 backend/app/skills_builtin/minimax-docx/scripts/dotnet/MiniMaxAIDocx.Cli/MiniMaxAIDocx.Cli.csproj create mode 100644 backend/app/skills_builtin/minimax-docx/scripts/dotnet/MiniMaxAIDocx.Cli/Program.cs create mode 100644 backend/app/skills_builtin/minimax-docx/scripts/dotnet/MiniMaxAIDocx.Core/Commands/AnalyzeCommand.cs create mode 100644 backend/app/skills_builtin/minimax-docx/scripts/dotnet/MiniMaxAIDocx.Core/Commands/ApplyTemplateCommand.cs create mode 100644 backend/app/skills_builtin/minimax-docx/scripts/dotnet/MiniMaxAIDocx.Core/Commands/CreateCommand.cs create mode 100644 backend/app/skills_builtin/minimax-docx/scripts/dotnet/MiniMaxAIDocx.Core/Commands/DiffCommand.cs create mode 100644 backend/app/skills_builtin/minimax-docx/scripts/dotnet/MiniMaxAIDocx.Core/Commands/EditContentCommand.cs create mode 100644 backend/app/skills_builtin/minimax-docx/scripts/dotnet/MiniMaxAIDocx.Core/Commands/FixOrderCommand.cs create mode 100644 backend/app/skills_builtin/minimax-docx/scripts/dotnet/MiniMaxAIDocx.Core/Commands/MergeRunsCommand.cs create mode 100644 backend/app/skills_builtin/minimax-docx/scripts/dotnet/MiniMaxAIDocx.Core/Commands/ValidateCommand.cs create mode 100644 backend/app/skills_builtin/minimax-docx/scripts/dotnet/MiniMaxAIDocx.Core/MiniMaxAIDocx.Core.csproj create mode 100644 backend/app/skills_builtin/minimax-docx/scripts/dotnet/MiniMaxAIDocx.Core/OpenXml/CommentSynchronizer.cs create mode 100644 backend/app/skills_builtin/minimax-docx/scripts/dotnet/MiniMaxAIDocx.Core/OpenXml/ElementOrder.cs create mode 100644 backend/app/skills_builtin/minimax-docx/scripts/dotnet/MiniMaxAIDocx.Core/OpenXml/NamespaceConstants.cs create mode 100644 backend/app/skills_builtin/minimax-docx/scripts/dotnet/MiniMaxAIDocx.Core/OpenXml/RunMerger.cs create mode 100644 backend/app/skills_builtin/minimax-docx/scripts/dotnet/MiniMaxAIDocx.Core/OpenXml/StyleAnalyzer.cs create mode 100644 backend/app/skills_builtin/minimax-docx/scripts/dotnet/MiniMaxAIDocx.Core/OpenXml/TrackChangesHelper.cs create mode 100644 backend/app/skills_builtin/minimax-docx/scripts/dotnet/MiniMaxAIDocx.Core/OpenXml/UnitConverter.cs create mode 100644 backend/app/skills_builtin/minimax-docx/scripts/dotnet/MiniMaxAIDocx.Core/Samples/AestheticRecipeSamples.cs create mode 100644 backend/app/skills_builtin/minimax-docx/scripts/dotnet/MiniMaxAIDocx.Core/Samples/AestheticRecipeSamples_Batch1.cs create mode 100644 backend/app/skills_builtin/minimax-docx/scripts/dotnet/MiniMaxAIDocx.Core/Samples/AestheticRecipeSamples_Batch2.cs create mode 100644 backend/app/skills_builtin/minimax-docx/scripts/dotnet/MiniMaxAIDocx.Core/Samples/AestheticRecipeSamples_Batch3.cs create mode 100644 backend/app/skills_builtin/minimax-docx/scripts/dotnet/MiniMaxAIDocx.Core/Samples/AestheticRecipeSamples_Batch4.cs create mode 100644 backend/app/skills_builtin/minimax-docx/scripts/dotnet/MiniMaxAIDocx.Core/Samples/CharacterFormattingSamples.cs create mode 100644 backend/app/skills_builtin/minimax-docx/scripts/dotnet/MiniMaxAIDocx.Core/Samples/DocumentCreationSamples.cs create mode 100644 backend/app/skills_builtin/minimax-docx/scripts/dotnet/MiniMaxAIDocx.Core/Samples/FieldAndTocSamples.cs create mode 100644 backend/app/skills_builtin/minimax-docx/scripts/dotnet/MiniMaxAIDocx.Core/Samples/FootnoteAndCommentSamples.cs create mode 100644 backend/app/skills_builtin/minimax-docx/scripts/dotnet/MiniMaxAIDocx.Core/Samples/HeaderFooterSamples.cs create mode 100644 backend/app/skills_builtin/minimax-docx/scripts/dotnet/MiniMaxAIDocx.Core/Samples/ImageSamples.cs create mode 100644 backend/app/skills_builtin/minimax-docx/scripts/dotnet/MiniMaxAIDocx.Core/Samples/ListAndNumberingSamples.cs create mode 100644 backend/app/skills_builtin/minimax-docx/scripts/dotnet/MiniMaxAIDocx.Core/Samples/ParagraphFormattingSamples.cs create mode 100644 backend/app/skills_builtin/minimax-docx/scripts/dotnet/MiniMaxAIDocx.Core/Samples/StyleSystemSamples.cs create mode 100644 backend/app/skills_builtin/minimax-docx/scripts/dotnet/MiniMaxAIDocx.Core/Samples/TableSamples.cs create mode 100644 backend/app/skills_builtin/minimax-docx/scripts/dotnet/MiniMaxAIDocx.Core/Samples/TrackChangesSamples.cs create mode 100644 backend/app/skills_builtin/minimax-docx/scripts/dotnet/MiniMaxAIDocx.Core/Typography/CjkHelper.cs create mode 100644 backend/app/skills_builtin/minimax-docx/scripts/dotnet/MiniMaxAIDocx.Core/Typography/FontDefaults.cs create mode 100644 backend/app/skills_builtin/minimax-docx/scripts/dotnet/MiniMaxAIDocx.Core/Typography/PageSizes.cs create mode 100644 backend/app/skills_builtin/minimax-docx/scripts/dotnet/MiniMaxAIDocx.Core/Validation/BusinessRuleValidator.cs create mode 100644 backend/app/skills_builtin/minimax-docx/scripts/dotnet/MiniMaxAIDocx.Core/Validation/GateCheckValidator.cs create mode 100644 backend/app/skills_builtin/minimax-docx/scripts/dotnet/MiniMaxAIDocx.Core/Validation/ValidationResult.cs create mode 100644 backend/app/skills_builtin/minimax-docx/scripts/dotnet/MiniMaxAIDocx.Core/Validation/XsdValidator.cs create mode 100644 backend/app/skills_builtin/minimax-docx/scripts/dotnet/MiniMaxAIDocx.slnx create mode 100755 backend/app/skills_builtin/minimax-docx/scripts/env_check.sh create mode 100644 backend/app/skills_builtin/minimax-docx/scripts/setup.ps1 create mode 100755 backend/app/skills_builtin/minimax-docx/scripts/setup.sh create mode 100644 backend/app/skills_builtin/minimax-pdf/README.md create mode 100644 backend/app/skills_builtin/minimax-pdf/SKILL.md create mode 100644 backend/app/skills_builtin/minimax-pdf/design/design.md create mode 100644 backend/app/skills_builtin/minimax-pdf/scripts/cover.py create mode 100644 backend/app/skills_builtin/minimax-pdf/scripts/fill_inspect.py create mode 100644 backend/app/skills_builtin/minimax-pdf/scripts/fill_write.py create mode 100644 backend/app/skills_builtin/minimax-pdf/scripts/make.sh create mode 100644 backend/app/skills_builtin/minimax-pdf/scripts/merge.py create mode 100644 backend/app/skills_builtin/minimax-pdf/scripts/palette.py create mode 100644 backend/app/skills_builtin/minimax-pdf/scripts/reformat_parse.py create mode 100644 backend/app/skills_builtin/minimax-pdf/scripts/render_body.py create mode 100644 backend/app/skills_builtin/minimax-pdf/scripts/render_cover.js create mode 100644 backend/app/skills_builtin/minimax-xlsx/SKILL.md create mode 100644 backend/app/skills_builtin/minimax-xlsx/references/create.md create mode 100644 backend/app/skills_builtin/minimax-xlsx/references/edit.md create mode 100644 backend/app/skills_builtin/minimax-xlsx/references/fix.md create mode 100644 backend/app/skills_builtin/minimax-xlsx/references/format.md create mode 100644 backend/app/skills_builtin/minimax-xlsx/references/ooxml-cheatsheet.md create mode 100644 backend/app/skills_builtin/minimax-xlsx/references/read-analyze.md create mode 100644 backend/app/skills_builtin/minimax-xlsx/references/validate.md create mode 100644 backend/app/skills_builtin/minimax-xlsx/scripts/formula_check.py create mode 100644 backend/app/skills_builtin/minimax-xlsx/scripts/libreoffice_recalc.py create mode 100644 backend/app/skills_builtin/minimax-xlsx/scripts/shared_strings_builder.py create mode 100644 backend/app/skills_builtin/minimax-xlsx/scripts/style_audit.py create mode 100644 backend/app/skills_builtin/minimax-xlsx/scripts/xlsx_add_column.py create mode 100644 backend/app/skills_builtin/minimax-xlsx/scripts/xlsx_insert_row.py create mode 100644 backend/app/skills_builtin/minimax-xlsx/scripts/xlsx_pack.py create mode 100644 backend/app/skills_builtin/minimax-xlsx/scripts/xlsx_reader.py create mode 100644 backend/app/skills_builtin/minimax-xlsx/scripts/xlsx_shift_rows.py create mode 100644 backend/app/skills_builtin/minimax-xlsx/scripts/xlsx_unpack.py create mode 100644 backend/app/skills_builtin/minimax-xlsx/templates/minimal_xlsx/[Content_Types].xml create mode 100644 backend/app/skills_builtin/minimax-xlsx/templates/minimal_xlsx/_rels/.rels create mode 100644 backend/app/skills_builtin/minimax-xlsx/templates/minimal_xlsx/xl/_rels/workbook.xml.rels create mode 100644 backend/app/skills_builtin/minimax-xlsx/templates/minimal_xlsx/xl/sharedStrings.xml create mode 100644 backend/app/skills_builtin/minimax-xlsx/templates/minimal_xlsx/xl/styles.xml create mode 100644 backend/app/skills_builtin/minimax-xlsx/templates/minimal_xlsx/xl/workbook.xml create mode 100644 backend/app/skills_builtin/minimax-xlsx/templates/minimal_xlsx/xl/worksheets/sheet1.xml create mode 100644 backend/app/skills_builtin/pptx-generator/SKILL.md create mode 100644 backend/app/skills_builtin/pptx-generator/references/design-system.md create mode 100644 backend/app/skills_builtin/pptx-generator/references/editing.md create mode 100644 backend/app/skills_builtin/pptx-generator/references/pitfalls.md create mode 100644 backend/app/skills_builtin/pptx-generator/references/pptxgenjs.md create mode 100644 backend/app/skills_builtin/pptx-generator/references/slide-types.md diff --git a/backend/app/skills_builtin/minimax-docx/.gitignore b/backend/app/skills_builtin/minimax-docx/.gitignore new file mode 100644 index 0000000..59072c5 --- /dev/null +++ b/backend/app/skills_builtin/minimax-docx/.gitignore @@ -0,0 +1,3 @@ +obj/ +bin/ +*.user diff --git a/backend/app/skills_builtin/minimax-docx/LICENSE b/backend/app/skills_builtin/minimax-docx/LICENSE new file mode 100644 index 0000000..53218a2 --- /dev/null +++ b/backend/app/skills_builtin/minimax-docx/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 MiniMaxAI + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/backend/app/skills_builtin/minimax-docx/SKILL.md b/backend/app/skills_builtin/minimax-docx/SKILL.md new file mode 100644 index 0000000..0d99f52 --- /dev/null +++ b/backend/app/skills_builtin/minimax-docx/SKILL.md @@ -0,0 +1,274 @@ +--- +name: minimax-docx +license: MIT +metadata: + version: "1.0.0" + category: document-processing + author: MiniMaxAI + sources: + - "ECMA-376 Office Open XML File Formats" + - "GB/T 9704-2012 Layout Standard for Official Documents" + - "IEEE / ACM / APA / MLA / Chicago / Turabian Style Guides" + - "Springer LNCS / Nature / HBR Document Templates" +description: > + Professional DOCX document creation, editing, and formatting using OpenXML SDK (.NET). + Three pipelines: (A) create new documents from scratch, (B) fill/edit content in existing + documents, (C) apply template formatting with XSD validation gate-check. + MUST use this skill whenever the user wants to produce, modify, or format a Word document — + including when they say "write a report", "draft a proposal", "make a contract", + "fill in this form", "reformat to match this template", or any task whose final output + is a .docx file. Even if the user doesn't mention "docx" explicitly, if the task + implies a printable/formal document, use this skill. +triggers: + - Word + - docx + - document + - 文档 + - Word文档 + - 报告 + - 合同 + - 公文 + - 排版 + - 套模板 +--- + +# minimax-docx + +Create, edit, and format DOCX documents via CLI tools or direct C# scripts built on OpenXML SDK (.NET). + +## Setup + +**First time:** `bash scripts/setup.sh` (or `powershell scripts/setup.ps1` on Windows, `--minimal` to skip optional deps). + +**First operation in session:** `scripts/env_check.sh` — do not proceed if `NOT READY`. (Skip on subsequent operations within the same session.) + +## Quick Start: Direct C# Path + +When the task requires structural document manipulation (custom styles, complex tables, multi-section layouts, headers/footers, TOC, images), write C# directly instead of wrestling with CLI limitations. Use this scaffold: + +```csharp +// File: scripts/dotnet/task.csx (or a new .cs in a Console project) +// dotnet run --project scripts/dotnet/MiniMaxAIDocx.Cli -- run-script task.csx +#r "nuget: DocumentFormat.OpenXml, 3.2.0" + +using DocumentFormat.OpenXml; +using DocumentFormat.OpenXml.Packaging; +using DocumentFormat.OpenXml.Wordprocessing; + +using var doc = WordprocessingDocument.Create("output.docx", WordprocessingDocumentType.Document); +var mainPart = doc.AddMainDocumentPart(); +mainPart.Document = new Document(new Body()); + +// --- Your logic here --- +// Read the relevant Samples/*.cs file FIRST for tested patterns. +// See Samples/ table in References section below. +``` + +**Before writing any C#, read the relevant `Samples/*.cs` file** — they contain compilable, SDK-version-verified patterns. The Samples table in the References section below maps topics to files. + +## CLI shorthand + +All CLI commands below use `$CLI` as shorthand for: +```bash +dotnet run --project scripts/dotnet/MiniMaxAIDocx.Cli -- +``` + +## Pipeline routing + +Route by checking: does the user have an input .docx file? + +``` +User task +├─ No input file → Pipeline A: CREATE +│ signals: "write", "create", "draft", "generate", "new", "make a report/proposal/memo" +│ → Read references/scenario_a_create.md +│ +└─ Has input .docx + ├─ Replace/fill/modify content → Pipeline B: FILL-EDIT + │ signals: "fill in", "replace", "update", "change text", "add section", "edit" + │ → Read references/scenario_b_edit_content.md + │ + └─ Reformat/apply style/template → Pipeline C: FORMAT-APPLY + signals: "reformat", "apply template", "restyle", "match this format", "套模板", "排版" + ├─ Template is pure style (no content) → C-1: OVERLAY (apply styles to source) + └─ Template has structure (cover/TOC/example sections) → C-2: BASE-REPLACE + (use template as base, replace example content with user content) + → Read references/scenario_c_apply_template.md +``` + +If the request spans multiple pipelines, run them sequentially (e.g., Create then Format-Apply). + +## Pre-processing + +Convert `.doc` → `.docx` if needed: `scripts/doc_to_docx.sh input.doc output_dir/` + +Preview before editing (avoids reading raw XML): `scripts/docx_preview.sh document.docx` + +Analyze structure for editing scenarios: `$CLI analyze --input document.docx` + +## Scenario A: Create + +Read `references/scenario_a_create.md`, `references/typography_guide.md`, and `references/design_principles.md` first. Pick an aesthetic recipe from `Samples/AestheticRecipeSamples.cs` that matches the document type — do not invent formatting values. For CJK, also read `references/cjk_typography.md`. + +**Choose your path:** +- **Simple** (plain text, minimal formatting): use CLI — `$CLI create --type report --output out.docx --config content.json` +- **Structural** (custom styles, multi-section, TOC, images, complex tables): write C# directly. Read the relevant `Samples/*.cs` first. + +CLI options: `--type` (report|letter|memo|academic), `--title`, `--author`, `--page-size` (letter|a4|legal|a3), `--margins` (standard|narrow|wide), `--header`, `--footer`, `--page-numbers`, `--toc`, `--content-json`. + +Then run the **validation pipeline** (below). + +## Scenario B: Edit / Fill + +Read `references/scenario_b_edit_content.md` first. Preview → analyze → edit → validate. + +**Choose your path:** +- **Simple** (text replacement, placeholder fill): use CLI subcommands. +- **Structural** (add/reorganize sections, modify styles, manipulate tables, insert images): write C# directly. Read `references/openxml_element_order.md` and the relevant `Samples/*.cs`. + +Available CLI edit subcommands: +- `replace-text --find "X" --replace "Y"` +- `fill-placeholders --data '{"key":"value"}'` +- `fill-table --data table.json` +- `insert-section`, `remove-section`, `update-header-footer` + +```bash +$CLI edit replace-text --input in.docx --output out.docx --find "OLD" --replace "NEW" +$CLI edit fill-placeholders --input in.docx --output out.docx --data '{"name":"John"}' +``` + +Then run the **validation pipeline**. Also run diff to verify minimal changes: +```bash +$CLI diff --before in.docx --after out.docx +``` + +## Scenario C: Apply Template + +Read `references/scenario_c_apply_template.md` first. Preview and analyze both source and template. + +```bash +$CLI apply-template --input source.docx --template template.docx --output out.docx +``` + +For complex template operations (multi-template merge, per-section headers/footers, style merging), write C# directly — see Critical Rules below for required patterns. + +Run the **validation pipeline**, then the **hard gate-check**: +```bash +$CLI validate --input out.docx --gate-check assets/xsd/business-rules.xsd +``` +Gate-check is a **hard requirement**. Do NOT deliver until it passes. If it fails: diagnose, fix, re-run. + +Also diff to verify content preservation: `$CLI diff --before source.docx --after out.docx` + +## Validation pipeline + +Run after every write operation. For Scenario C the full pipeline is **mandatory**; for A/B it is **recommended** (skip only if the operation was trivially simple). + +```bash +$CLI merge-runs --input doc.docx # 1. consolidate runs +$CLI validate --input doc.docx --xsd assets/xsd/wml-subset.xsd # 2. XSD structure +$CLI validate --input doc.docx --business # 3. business rules +``` + +If XSD fails, auto-repair and retry: +```bash +$CLI fix-order --input doc.docx +$CLI validate --input doc.docx --xsd assets/xsd/wml-subset.xsd +``` + +If XSD still fails, fall back to business rules + preview: +```bash +$CLI validate --input doc.docx --business +scripts/docx_preview.sh doc.docx +# Verify: font contamination=0, table count correct, drawing count correct, sectPr count correct +``` + +Final preview: `scripts/docx_preview.sh doc.docx` + +## Critical rules + +These prevent file corruption — OpenXML is strict about element ordering. + +**Element order** (properties always first): + +| Parent | Order | +|--------|-------| +| `w:p` | `pPr` → runs | +| `w:r` | `rPr` → `t`/`br`/`tab` | +| `w:tbl`| `tblPr` → `tblGrid` → `tr` | +| `w:tr` | `trPr` → `tc` | +| `w:tc` | `tcPr` → `p` (min 1 ``) | +| `w:body` | block content → `sectPr` (LAST child) | + +**Direct format contamination:** When copying content from a source document, inline `rPr` (fonts, color) and `pPr` (borders, shading, spacing) override template styles. Always strip direct formatting — keep only `pStyle` reference and `t` text. Clean tables too (including `pPr/rPr` inside cells). + +**Track changes:** `` uses ``, never ``. `` uses ``, never ``. + +**Font size:** `w:sz` = points × 2 (12pt → `sz="24"`). Margins/spacing in DXA (1 inch = 1440, 1cm ≈ 567). + +**Heading styles MUST have OutlineLevel:** When defining heading styles (Heading1, ThesisH1, etc.), always include `new OutlineLevel { Val = N }` in `StyleParagraphProperties` (H1→0, H2→1, H3→2). Without this, Word sees them as plain styled text — TOC and navigation pane won't work. + +**Multi-template merge:** When given multiple template files (font, heading, breaks), read `references/scenario_c_apply_template.md` section "Multi-Template Merge" FIRST. Key rules: +- Merge styles from all templates into one styles.xml. Structure (sections/breaks) comes from the breaks template. +- Each content paragraph must appear exactly ONCE — never duplicate when inserting section breaks. +- NEVER insert empty/blank paragraphs as padding or section separators. Output paragraph count must equal input. Use section break properties (`w:sectPr` inside `w:pPr`) and style spacing (`w:spacing` before/after) for visual separation. +- Insert oddPage section breaks before EVERY chapter heading, not just the first. Even if a chapter has dual-column content, it MUST start with oddPage; use a second continuous break after the heading for column switching. +- Dual-column chapters need THREE section breaks: (1) oddPage in preceding para's pPr, (2) continuous+cols=2 in the chapter HEADING's pPr, (3) continuous+cols=1 in the last body para's pPr to revert. +- Copy `titlePg` settings from the breaks template for EACH section. Abstract and TOC sections typically need `titlePg=true`. + +**Multi-section headers/footers:** Templates with 10+ sections (e.g., Chinese thesis) have DIFFERENT headers/footers per section (Roman vs Arabic page numbers, different header text per zone). Rules: +- Use C-2 Base-Replace: copy the TEMPLATE as output base, then replace body content. This preserves all sections, headers, footers, and titlePg settings automatically. +- NEVER recreate headers/footers from scratch — copy template header/footer XML byte-for-byte. +- NEVER add formatting (borders, alignment, font size) not present in the template header XML. +- Non-cover sections MUST have header/footer XML files (at least empty header + page number footer). +- See `references/scenario_c_apply_template.md` section "Multi-Section Header/Footer Transfer". + +## References + +Load as needed — don't load all at once. Pick the most relevant files for the task. + +**The C# samples and design references below are the project's knowledge base ("encyclopedia").** When writing OpenXML code, ALWAYS read the relevant sample file first — it contains compilable, SDK-version-verified patterns that prevent common errors. When making aesthetic decisions, read the design principles and recipe files — they encode tested, harmonious parameter sets from authoritative sources (IEEE, ACM, APA, Nature, etc.), not guesses. + +### Scenario guides (read first for each pipeline) + +| File | When | +|------|------| +| `references/scenario_a_create.md` | Pipeline A: creating from scratch | +| `references/scenario_b_edit_content.md` | Pipeline B: editing existing content | +| `references/scenario_c_apply_template.md` | Pipeline C: applying template formatting | + +### C# code samples (compilable, heavily commented — read when writing code) + +| File | Topic | +|------|-------| +| `Samples/DocumentCreationSamples.cs` | Document lifecycle: create, open, save, streams, doc defaults, settings, properties, page setup, multi-section | +| `Samples/StyleSystemSamples.cs` | Styles: Normal/Heading chain, character/table/list styles, DocDefaults, latentStyles, CJK 公文, APA 7th, import, resolve inheritance | +| `Samples/CharacterFormattingSamples.cs` | RunProperties: fonts, size, bold/italic, all underlines, color, highlight, strike, sub/super, caps, spacing, shading, border, emphasis marks | +| `Samples/ParagraphFormattingSamples.cs` | ParagraphProperties: justification, indentation, line/paragraph spacing, keep/widow, outline level, borders, tabs, numbering, bidi, frame | +| `Samples/TableSamples.cs` | Tables: borders, grid, cell props, margins, row height, header repeat, merge (H+V), nested, floating, three-line 三线表, zebra striping | +| `Samples/HeaderFooterSamples.cs` | Headers/footers: page numbers, "Page X of Y", first/even/odd, logo image, table layout, 公文 "-X-", per-section | +| `Samples/ImageSamples.cs` | Images: inline, floating, text wrapping, border, alt text, in header/table, replace, SVG fallback, dimension calc | +| `Samples/ListAndNumberingSamples.cs` | Numbering: bullets, multi-level decimal, custom symbols, outline→headings, legal, Chinese 一/(一)/1./(1), restart/continue | +| `Samples/FieldAndTocSamples.cs` | Fields: TOC, SimpleField vs complex field, DATE/PAGE/REF/SEQ/MERGEFIELD/IF/STYLEREF, TOC styles | +| `Samples/FootnoteAndCommentSamples.cs` | Footnotes, endnotes, comments (4-file system), bookmarks, hyperlinks (internal + external) | +| `Samples/TrackChangesSamples.cs` | Revisions: insertions (w:t), deletions (w:delText!), formatting changes, accept/reject all, move tracking | +| `Samples/AestheticRecipeSamples.cs` | 13 aesthetic recipes from authoritative sources: ModernCorporate, AcademicThesis, ExecutiveBrief, ChineseGovernment (GB/T 9704), MinimalModern, IEEE Conference, ACM sigconf, APA 7th, MLA 9th, Chicago/Turabian, Springer LNCS, Nature, HBR — each with exact values from official style guides | + +Note: `Samples/` path is relative to `scripts/dotnet/MiniMaxAIDocx.Core/`. + +### Markdown references (read when you need specifications or design rules) + +| File | When | +|------|------| +| `references/openxml_element_order.md` | XML element ordering rules (prevents corruption) | +| `references/openxml_units.md` | Unit conversion: DXA, EMU, half-points, eighth-points | +| `references/openxml_encyclopedia_part1.md` | Detailed C# encyclopedia: document creation, styles, character & paragraph formatting | +| `references/openxml_encyclopedia_part2.md` | Detailed C# encyclopedia: page setup, tables, headers/footers, sections, doc properties | +| `references/openxml_encyclopedia_part3.md` | Detailed C# encyclopedia: TOC, footnotes, fields, track changes, comments, images, math, numbering, protection | +| `references/typography_guide.md` | Font pairing, sizes, spacing, page layout, table design, color schemes | +| `references/cjk_typography.md` | CJK fonts, 字号 sizes, RunFonts mapping, GB/T 9704 公文 standard | +| `references/cjk_university_template_guide.md` | Chinese university thesis templates: numeric styleIds (1/2/3 vs Heading1), document zone structure (cover→abstract→TOC→body→references), font expectations, common mistakes | +| `references/design_principles.md` | **Aesthetic foundations**: 6 design principles (white space, contrast/scale, proximity, alignment, repetition, hierarchy) — teaches WHY, not just WHAT | +| `references/design_good_bad_examples.md` | **Good vs Bad comparisons**: 10 categories of typography mistakes with OpenXML values, ASCII mockups, and fixes | +| `references/track_changes_guide.md` | Revision marks deep dive | +| `references/troubleshooting.md` | **Symptom-driven fixes**: 13 common problems indexed by what you SEE (headings wrong, images missing, TOC broken, etc.) — search by symptom, find the fix | diff --git a/backend/app/skills_builtin/minimax-docx/assets/styles/academic_styles.xml b/backend/app/skills_builtin/minimax-docx/assets/styles/academic_styles.xml new file mode 100644 index 0000000..85d1d06 --- /dev/null +++ b/backend/app/skills_builtin/minimax-docx/assets/styles/academic_styles.xml @@ -0,0 +1,250 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/backend/app/skills_builtin/minimax-docx/assets/styles/corporate_styles.xml b/backend/app/skills_builtin/minimax-docx/assets/styles/corporate_styles.xml new file mode 100644 index 0000000..5d7e2fa --- /dev/null +++ b/backend/app/skills_builtin/minimax-docx/assets/styles/corporate_styles.xml @@ -0,0 +1,284 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/backend/app/skills_builtin/minimax-docx/assets/styles/default_styles.xml b/backend/app/skills_builtin/minimax-docx/assets/styles/default_styles.xml new file mode 100644 index 0000000..6efe7f8 --- /dev/null +++ b/backend/app/skills_builtin/minimax-docx/assets/styles/default_styles.xml @@ -0,0 +1,449 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/backend/app/skills_builtin/minimax-docx/assets/xsd/aesthetic-rules.xsd b/backend/app/skills_builtin/minimax-docx/assets/xsd/aesthetic-rules.xsd new file mode 100644 index 0000000..e423035 --- /dev/null +++ b/backend/app/skills_builtin/minimax-docx/assets/xsd/aesthetic-rules.xsd @@ -0,0 +1,470 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Body text font size in half-points. + Acceptable range: 20-28 (10pt-14pt). + - 10pt (20): minimum for comfortable reading + - 11pt (22): modern default (Calibri, Aptos) + - 12pt (24): traditional default (Times New Roman) + - 14pt (28): maximum before body text looks oversized + + + + + + + + + + + + + + + + + + + + + + + Heading font size in half-points. + Acceptable range: 24-52 (12pt-26pt). + - 12pt (24): APA-style (hierarchy via bold/italic, not size) + - 16pt (32): typical H2/H3 + - 20pt (40): typical H1 + - 26pt (52): maximum before headings dominate the page + + + + + + + + + + + + + + + + + + + + + Line spacing value for auto line-spacing rule. + In 240ths of single spacing: 240 = 1.0x, 480 = 2.0x. + Acceptable range: 240-560 (1.0x to 2.33x). + Common values: + - 240: single spacing (dense, technical) + - 259: Word's 1.08x default + - 276: 1.15x (modern corporate default) + - 336: 1.4x (executive/generous) + - 360: 1.5x (generous/minimal) + - 480: 2.0x (academic double spacing) + + + + + + + + + + + + + + + + + + + Fixed line spacing value (lineRule="exact") in DXA. + Acceptable range: 200-720 (10pt-36pt). + - 560: Chinese government standard (28pt, for 16pt body) + - 480: double-space equivalent for 12pt body + + + + + + + + + + + + + + + + + + + + + + Page margin in DXA. Minimum 720 (0.5 inch), maximum 4320 (3 inches). + Common values: + - 720: 0.5in (minimum printable) + - 1440: 1.0in (standard US) + - 1588: 28mm (Chinese government left margin) + - 1800: 1.25in (executive/premium) + - 2160: 1.5in (binding margin or narrow-column design) + + + + + + + + + + + + + + Vertical (top/bottom) page margin in DXA. + Range: 360 to 4320 (0.25in to 3in). + Slightly more permissive than horizontal margins because + header/footer areas may reduce effective vertical margin. + + + + + + + + + + + + + + + + + + + Paragraph spacing (before/after) in DXA. + Range: 0-960 (0pt-48pt). + Common values: + - 0: academic style (uses first-line indent instead) + - 80: 4pt (tight, used after H2/H3) + - 120: 6pt (moderate) + - 160: 8pt (standard modern spacing) + - 200: 10pt (generous/executive) + - 240: 12pt (very generous/minimal) + - 480: 24pt (heading before — creates section break) + + + + + + + + + + + + + + + + + + + + Table cell padding in DXA. Minimum 28 DXA (~1.4pt). + Recommended: 57 DXA (~2.85pt) for comfortable spacing. + Maximum: 288 DXA (~14pt) — beyond this wastes space. + + + + + + + + + + + + + + + + + + + Border width in eighth-points. + Range: 2-24 (0.25pt to 3pt). + Common values: + - 4: 0.5pt (thin, standard) + - 6: 0.75pt (header separator in three-line tables) + - 8: 1.0pt (medium, good for framing borders) + - 12: 1.5pt (heavy, used for top/bottom in three-line tables) + - 24: 3.0pt (maximum before borders dominate) + + + + + + + + + + + + + + + + + + Color value: 6-digit hex (RRGGBB) or "auto". + Examples: "000000", "1F3864", "2C3E50", "auto". + + + + + + + + + + + + + + + + + + + First-line indent in DXA. Range: 0-1440 (0in to 1.0in). + - 0: no indent (modern style with space-after) + - 480: 0.33in (compact) + - 640: ~0.44in (2 Chinese characters at 16pt) + - 720: 0.5in (standard APA/academic) + - 1440: 1.0in (maximum before it looks wrong) + + + + + + + + + + + + + + + + + Aesthetic run properties validator. + Checks font size and color format at the run level. + + + + + + + + + + + + + + + + + + + + + + + + + + + + Aesthetic spacing validator for paragraph spacing properties. + Validates line spacing and before/after spacing are in range. + + + + + + + + + + + + + + + + + + + + + + + Aesthetic page margin validator. + Ensures all margins meet minimum print-safe thresholds. + + + + + + + + + + + + + + + + + + Aesthetic table cell margin validator. + Ensures minimum padding for readability. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/backend/app/skills_builtin/minimax-docx/assets/xsd/business-rules.xsd b/backend/app/skills_builtin/minimax-docx/assets/xsd/business-rules.xsd new file mode 100644 index 0000000..c8e29e4 --- /dev/null +++ b/backend/app/skills_builtin/minimax-docx/assets/xsd/business-rules.xsd @@ -0,0 +1,130 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/backend/app/skills_builtin/minimax-docx/assets/xsd/common-types.xsd b/backend/app/skills_builtin/minimax-docx/assets/xsd/common-types.xsd new file mode 100644 index 0000000..c90a487 --- /dev/null +++ b/backend/app/skills_builtin/minimax-docx/assets/xsd/common-types.xsd @@ -0,0 +1,159 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/backend/app/skills_builtin/minimax-docx/assets/xsd/wml-subset.xsd b/backend/app/skills_builtin/minimax-docx/assets/xsd/wml-subset.xsd new file mode 100644 index 0000000..fb2416d --- /dev/null +++ b/backend/app/skills_builtin/minimax-docx/assets/xsd/wml-subset.xsd @@ -0,0 +1,589 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/backend/app/skills_builtin/minimax-docx/references/cjk_typography.md b/backend/app/skills_builtin/minimax-docx/references/cjk_typography.md new file mode 100644 index 0000000..e468f10 --- /dev/null +++ b/backend/app/skills_builtin/minimax-docx/references/cjk_typography.md @@ -0,0 +1,357 @@ +# CJK Typography & Mixed-Script Guide + +Rules for Chinese, Japanese, and Korean text in DOCX documents. + +## Table of Contents + +1. [Font Selection](#font-selection) +2. [Font Size Names (CJK)](#font-size-names) +3. [RunFonts Mapping](#runfonts-mapping) +4. [Punctuation & Line Breaking](#punctuation--line-breaking) +5. [Paragraph Indentation](#paragraph-indentation) +6. [Line Spacing for CJK](#line-spacing) +7. [Chinese Government Standard (GB/T 9704)](#gbt-9704) +8. [Mixed CJK + Latin Best Practices](#mixed-script) +9. [OpenXML Quick Reference](#openxml-quick-reference) + +--- + +## Font Selection + +### Recommended CJK Fonts + +| Language | Serif (正文) | Sans (标题) | Notes | +|----------|-------------|-------------|-------| +| **Simplified Chinese** | 宋体 (SimSun) | 微软雅黑 (Microsoft YaHei) | YaHei for screen, SimSun for print | +| **Simplified Chinese** | 仿宋 (FangSong) | 黑体 (SimHei) | Government documents | +| **Traditional Chinese** | 新細明體 (PMingLiU) | 微軟正黑體 (Microsoft JhengHei) | Taiwan standard | +| **Japanese** | MS 明朝 (MS Mincho) | MS ゴシック (MS Gothic) | Classic pairing | +| **Japanese** | 游明朝 (Yu Mincho) | 游ゴシック (Yu Gothic) | Modern, Windows 10+ | +| **Korean** | 바탕 (Batang) | 맑은 고딕 (Malgun Gothic) | Standard pairing | + +### Government Document Fonts (公文) + +| Element | Font | Size | +|---------|------|------| +| 标题 (title) | 小标宋 (FZXiaoBiaoSong-B05S) | 二号 (22pt) | +| 一级标题 | 黑体 (SimHei) | 三号 (16pt) | +| 二级标题 | 楷体_GB2312 (KaiTi_GB2312) | 三号 (16pt) | +| 三级标题 | 仿宋_GB2312 加粗 | 三号 (16pt) | +| 正文 (body) | 仿宋_GB2312 (FangSong_GB2312) | 三号 (16pt) | +| 附注/页码 | 宋体 (SimSun) | 四号 (14pt) | + +--- + +## Font Size Names + +CJK uses named sizes. Map to points and `w:sz` half-point values: + +| 字号 | Points | `w:sz` | Common Use | +|------|--------|--------|------------| +| 初号 | 42pt | 84 | Display title | +| 小初 | 36pt | 72 | Large title | +| 一号 | 26pt | 52 | Chapter heading | +| 小一 | 24pt | 48 | Major heading | +| 二号 | 22pt | 44 | Document title (公文) | +| 小二 | 18pt | 36 | Western H1 equivalent | +| 三号 | 16pt | 32 | CJK heading / 公文 body | +| 小三 | 15pt | 30 | Sub-heading | +| 四号 | 14pt | 28 | CJK subheading | +| 小四 | 12pt | 24 | Standard body (CJK) | +| 五号 | 10.5pt | 21 | Compact CJK body | +| 小五 | 9pt | 18 | Footnotes | +| 六号 | 7.5pt | 15 | Fine print | + +--- + +## RunFonts Mapping + +OpenXML uses four font slots to handle multilingual text: + +```xml + + w:hAnsi="Calibri" + w:eastAsia="SimSun" + w:cs="Arial" +/> +``` + +**Word's character classification logic:** + +1. Character is in CJK range → uses `w:eastAsia` font +2. Character is in complex script range → uses `w:cs` font +3. Character is basic Latin (ASCII) → uses `w:ascii` font +4. Everything else → uses `w:hAnsi` font + +**Key**: `w:eastAsia` is the **only** way to set CJK fonts. Setting just `w:ascii` will NOT affect CJK characters. Mixed text within a single run auto-switches fonts at the character level — no need for separate runs. + +### Document Defaults + +```xml + + + + + + + + + + +``` + +`w:lang w:eastAsia` helps Word resolve ambiguous characters (e.g., punctuation shared between CJK and Latin). + +--- + +## Punctuation & Line Breaking + +### Full-Width vs Half-Width + +CJK text uses full-width punctuation: + +| Type | CJK | Latin | +|------|-----|-------| +| Period | 。(U+3002) | . | +| Comma | ,(U+FF0C) 、(U+3001) | , | +| Colon | :(U+FF1A) | : | +| Semicolon | ;(U+FF1B) | ; | +| Quotes | 「」『』 or ""'' | "" '' | +| Parentheses | ()(U+FF08/09) | () | + +In mixed text, use the punctuation style of the **surrounding language context**. + +### OpenXML Controls + +```xml + + + + + + +``` + +### Kinsoku Rules (禁則処理) + +Prevents certain characters from appearing at the start or end of a line: +- **Cannot start a line**: `)」』】〉》。、,!?;:` and closing brackets +- **Cannot end a line**: `(「『【〈《` and opening brackets + +Word applies these automatically when `w:kinsoku` is enabled. + +### Line Breaking + +- CJK characters can break between **any two characters** (no word boundaries needed) +- Latin words within CJK text still follow word-boundary breaking +- `w:wordWrap w:val="false"` enables CJK-style breaking (break anywhere) + +--- + +## Paragraph Indentation + +### Chinese Standard: 2-Character Indent + +Chinese body text conventionally uses a 2-character first-line indent: + +```xml + +``` + +Preferred over `w:firstLine` with fixed DXA because `firstLineChars` scales with font size. + +| Indent | Value | +|--------|-------| +| 1 character | `w:firstLineChars="100"` | +| 2 characters | `w:firstLineChars="200"` | +| 3 characters | `w:firstLineChars="300"` | + +--- + +## Line Spacing + +- CJK characters are taller than Latin characters at the same point size +- Default `1.0` line spacing may feel cramped with CJK text +- Recommended: `1.15–1.5` for mixed CJK+Latin, `1.0` with fixed 28pt for 公文 + +### Auto Spacing + +```xml + + + + +``` + +Adds ~¼ em spacing between CJK and non-CJK characters automatically. **Recommended: always enable.** + +--- + +## GB/T 9704 + +Chinese government document standard (党政机关公文格式). These are **strict requirements**, not suggestions. + +### Page Setup + +| Parameter | Value | OpenXML | +|-----------|-------|---------| +| Page size | A4 (210×297mm) | Width=11906, Height=16838 | +| Top margin | 37mm | 2098 DXA | +| Bottom margin | 35mm | 1984 DXA | +| Left margin | 28mm | 1588 DXA | +| Right margin | 26mm | 1474 DXA | +| Characters/line | 28 | | +| Lines/page | 22 | | +| Line spacing | Fixed 28pt | `line="560"` lineRule="exact" | + +### Document Structure + +``` +┌─────────────────────────────────┐ +│ 发文机关标志 (红头) │ ← 小标宋 or 红色大字 +│ ══════════════════ (红线) │ ← Red #FF0000, 2pt +├─────────────────────────────────┤ +│ 发文字号: X机发〔2025〕X号 │ ← 仿宋 三号, centered +│ │ +│ 标题 (Title) │ ← 小标宋 二号, centered +│ │ 可分多行,回行居中 +│ 主送机关: │ ← 仿宋 三号 +│ │ +│ 正文 (Body)... │ ← 仿宋_GB2312 三号 +│ 一、一级标题 │ ← 黑体 三号 +│ (一)二级标题 │ ← 楷体 三号 +│ 1. 三级标题 │ ← 仿宋 三号 加粗 +│ (1) 四级标题 │ ← 仿宋 三号 +│ │ +│ 附件: 1. xxx │ ← 仿宋 三号 +│ │ +│ 发文机关署名 │ ← 仿宋 三号 +│ 成文日期 │ ← 仿宋 三号, 小写中文数字 +├─────────────────────────────────┤ +│ ══════════════════ (版记线) │ +│ 抄送: xxx │ ← 仿宋 四号 +│ 印发机关及日期 │ ← 仿宋 四号 +└─────────────────────────────────┘ +``` + +### Numbering System + +``` +一、 ← 黑体 (SimHei), no indentation +(一) ← 楷体 (KaiTi), indented 2 chars +1. ← 仿宋加粗 (FangSong Bold), indented 2 chars +(1) ← 仿宋 (FangSong), indented 2 chars +``` + +### Colors + +| Element | Color | Requirement | +|---------|-------|-------------| +| All body text | Black #000000 | Mandatory | +| 红头 (agency name) | Red #FF0000 | Mandatory | +| 红线 (separator) | Red #FF0000 | Mandatory | +| 公章 (official seal) | Red | Mandatory | + +### Page Numbers + +- Position: bottom center +- Format: `-X-` (dash-number-dash) +- Font: 宋体 四号 (SimSun 14pt, `sz="28"`) +- No page number on cover page if present + +--- + +## Mixed Script + +### Font Size Harmony + +CJK characters appear larger than Latin characters at the same point size. Compensation: + +- If body is Calibri 11pt, pair with CJK at 11pt (same size — CJK looks slightly larger but acceptable) +- If precise visual match needed, CJK can be set 0.5–1pt smaller +- In practice, same point size is standard — don't over-optimize + +### Bold and Italic + +- **Chinese/Japanese have no true italic.** Word synthesizes a slant which looks poor +- Use **bold** for emphasis in CJK text +- Use 着重号 (emphasis dots) for traditional emphasis: `` on RunProperties + +--- + +## OpenXML Quick Reference + +### Set EastAsia Font (C#) + +```csharp +new Run( + new RunProperties( + new RunFonts { EastAsia = "SimSun", Ascii = "Calibri", HighAnsi = "Calibri" }, + new FontSize { Val = "32" } // 三号 = 16pt = sz 32 + ), + new Text("这是正文内容") +); +``` + +### Document Defaults (C#) + +```csharp +new DocDefaults(new RunPropertiesDefault(new RunPropertiesBaseStyle( + new RunFonts { + Ascii = "Calibri", HighAnsi = "Calibri", + EastAsia = "Microsoft YaHei" + }, + new Languages { Val = "en-US", EastAsia = "zh-CN" } +))); +``` + +### 公文 Style Definitions (C#) + +```csharp +// Title style — 小标宋 二号 centered +new Style( + new StyleName { Val = "GongWen Title" }, + new BasedOn { Val = "Normal" }, + new StyleRunProperties( + new RunFonts { EastAsia = "FZXiaoBiaoSong-B05S" }, + new FontSize { Val = "44" }, // 二号 = 22pt + new Bold() + ), + new StyleParagraphProperties( + new Justification { Val = JustificationValues.Center }, + new SpacingBetweenLines { Line = "560", LineRule = LineSpacingRuleValues.Exact } + ) +) { Type = StyleValues.Paragraph, StyleId = "GongWenTitle" }; + +// Body style — 仿宋_GB2312 三号 +new Style( + new StyleName { Val = "GongWen Body" }, + new StyleRunProperties( + new RunFonts { EastAsia = "FangSong_GB2312", Ascii = "FangSong_GB2312" }, + new FontSize { Val = "32" } // 三号 = 16pt + ), + new StyleParagraphProperties( + new SpacingBetweenLines { Line = "560", LineRule = LineSpacingRuleValues.Exact } + ) +) { Type = StyleValues.Paragraph, StyleId = "GongWenBody" }; +``` + +### Emphasis Dots (着重号) + +```csharp +new RunProperties(new Emphasis { Val = EmphasisMarkValues.Dot }); +``` + +### East Asian Text Layout + +```xml + + + + + + + + +``` diff --git a/backend/app/skills_builtin/minimax-docx/references/cjk_university_template_guide.md b/backend/app/skills_builtin/minimax-docx/references/cjk_university_template_guide.md new file mode 100644 index 0000000..da4cfb0 --- /dev/null +++ b/backend/app/skills_builtin/minimax-docx/references/cjk_university_template_guide.md @@ -0,0 +1,184 @@ +# Chinese University Thesis Template Guide (中国高校论文模板指南) + +## Why This Guide Exists + +Chinese university thesis templates (.docx) have structural patterns that differ significantly +from Western templates. Agents that assume Western conventions (Heading1/Heading2/Normal) will +fail repeatedly. This guide documents the ACTUAL patterns found in Chinese templates. + +## Common StyleId Patterns + +### Pattern A: Numeric IDs (most common in Chinese Word templates) + +| Style Purpose | styleId | w:name | w:basedOn | +|--------------|---------|--------|-----------| +| Normal body | `a` | "Normal" | — | +| Default paragraph font | `a0` | "Default Paragraph Font" | — | +| Heading 1 (章标题) | `1` | "heading 1" | `a` | +| Heading 2 (节标题) | `2` | "heading 2" | `a` | +| Heading 3 (小节标题) | `3` | "heading 3" | `a` | +| TOC 1 | `11` | "toc 1" | `a` | +| TOC 2 | `21` | "toc 2" | `a` | +| TOC 3 | `31` | "toc 3" | `a` | +| Header | `a3` | "header" | `a` | +| Footer | `a4` | "footer" | `a` | +| Table of Contents heading | `10` | "TOC Heading" | `1` | + +### Pattern B: English IDs (less common, usually from international templates) +Standard Heading1/Heading2/Heading3/Normal — these follow the Western pattern. + +### Pattern C: Mixed (some Chinese, some English) +Some templates define custom styles with Chinese names: +| Style Purpose | styleId | w:name | +|--------------|---------|--------| +| 论文标题 | `lunwenbiaoti` | "论文标题" | +| 章标题 | `zhangbiaoti` | "章标题" | +| 正文 | `zhengwen` | "正文" | + +### How to Identify Which Pattern + +```bash +# Extract all styleIds from the template +$CLI analyze --input template.docx --styles-only + +# Or manually: +# unzip template.docx word/styles.xml +# Search for w:styleId= in the extracted file +``` + +Look at the first few styleIds. If you see `1`, `2`, `3`, `a`, `a0` → Pattern A. +If you see `Heading1`, `Normal` → Pattern B. + +## Standard Thesis Structure + +Chinese university theses follow a highly standardized structure: + +``` +┌─────────────────────────────────────┐ +│ 封面 (Cover Page) │ ← Usually 1-2 pages +│ - 校名、校徽 │ +│ - 论文题目 (title) │ +│ - 作者、导师、院系、日期 │ +├─────────────────────────────────────┤ +│ 学术诚信承诺书 / 独创性声明 │ ← 1 page +│ (Academic Integrity Declaration) │ +├─────────────────────────────────────┤ +│ 中文摘要 (Chinese Abstract) │ ← 1-2 pages +│ - "摘 要" heading │ +│ - Abstract body │ +│ - "关键词:" line │ +├─────────────────────────────────────┤ +│ 英文摘要 (English Abstract) │ ← 1-2 pages +│ - "ABSTRACT" heading │ +│ - Abstract body │ +│ - "Keywords:" line │ +├─────────────────────────────────────┤ +│ 目录 (Table of Contents) │ ← 1-3 pages +│ - Often inside SDT block │ +│ - Static example entries │ +│ - TOC field code │ +├─────────────────────────────────────┤ +│ 正文 (Body) │ ← Main content +│ 第1章 绪论 │ +│ 1.1 研究背景 │ +│ 1.2 研究目的和意义 │ +│ 第2章 文献综述 │ +│ ... │ +│ 第N章 结论与展望 │ +├─────────────────────────────────────┤ +│ 参考文献 (References) │ ← Styled differently +├─────────────────────────────────────┤ +│ 致谢 (Acknowledgments) │ ← Optional +├─────────────────────────────────────┤ +│ 附录 (Appendices) │ ← Optional +└─────────────────────────────────────┘ +``` + +## Identifying Zone Boundaries in Templates + +Templates contain EXAMPLE content that must be replaced. Here's how to find the zones: + +### Zone A (Front matter) — KEEP from template +- Starts at: paragraph 0 +- Ends at: the paragraph BEFORE the first chapter heading +- Contains: cover, declaration, abstracts, TOC +- How to detect end: search for first paragraph with style `1` (or Heading1) containing "第1章" or "绪论" + +### Zone B (Body content) — REPLACE with user content +- Starts at: first chapter heading ("第1章...") +- Ends at: "参考文献" heading (inclusive) or last body paragraph before acknowledgments +- How to detect: + ```python + for i, el in enumerate(body_elements): + text = get_text(el) + style = get_style(el) + if style in ('1', 'Heading1') and ('第1章' in text or '绪论' in text): + zone_b_start = i + if '参考文献' in text: + zone_b_end = i + ``` + +### Zone C (Back matter) — KEEP from template (or remove) +- Starts after: 参考文献 +- Contains: 致谢, 附录, final sectPr + +## Font Expectations in Chinese Thesis Templates + +| Element | Font | Size (字号) | Size (pt) | w:sz | +|---------|------|------------|-----------|------| +| 论文标题 | 华文中宋 or 黑体 | 二号 or 小二 | 22pt or 18pt | 44 or 36 | +| 章标题 (H1) | 黑体 | 三号 | 16pt | 32 | +| 节标题 (H2) | 黑体 | 四号 | 14pt | 28 | +| 小节标题 (H3) | 黑体 | 小四 | 12pt | 24 | +| 正文 | 宋体 | 小四 | 12pt | 24 | +| 页眉 | 宋体 | 五号 | 10.5pt | 21 | +| 页脚/页码 | 宋体 | 五号 | 10.5pt | 21 | +| 表格内容 | 宋体 | 五号 | 10.5pt | 21 | +| 参考文献条目 | 宋体 | 五号 | 10.5pt | 21 | + +## RunFonts for CJK Body Text + +```xml + +``` + +For headings: +```xml + +``` + +IMPORTANT: When cleaning direct formatting, ALWAYS preserve w:eastAsia. +Removing it causes Chinese text to fall back to the wrong font. + +## Common Mistakes with Chinese Templates + +1. **Searching for `Heading1`** — Chinese templates use `1`, not `Heading1` +2. **Clearing all rFonts** — Must keep eastAsia font declarations +3. **Assuming "第1章" is the first paragraph** — It's typically paragraph 100+ after cover/abstract/TOC +4. **Ignoring SDT blocks in TOC** — The TOC is wrapped in an SDT, not just field codes +5. **Wrong line spacing** — Chinese theses typically use fixed 20pt (line="400") or 22pt (line="440"), not the 28pt used in government documents +6. **Missing section breaks** — Each zone (abstract, TOC, body) usually has its own sectPr for different headers/footers + +## Style Mapping Quick Reference + +When source document uses Western IDs and template uses Chinese numeric IDs: + +```json +{ + "Heading1": "1", + "Heading2": "2", + "Heading3": "3", + "Heading4": "3", + "Normal": "a", + "BodyText": "a", + "ListParagraph": "a", + "Caption": "a", + "TOC1": "11", + "TOC2": "21", + "TOC3": "31" +} +``` + +When source uses Chinese numeric IDs and template uses Western IDs — reverse the mapping. diff --git a/backend/app/skills_builtin/minimax-docx/references/comments_guide.md b/backend/app/skills_builtin/minimax-docx/references/comments_guide.md new file mode 100644 index 0000000..fa12493 --- /dev/null +++ b/backend/app/skills_builtin/minimax-docx/references/comments_guide.md @@ -0,0 +1,191 @@ +# Comments System Guide (4-File Architecture) + +## Overview + +Word comments require coordination across **four XML files** plus references in `document.xml`, `[Content_Types].xml`, and `document.xml.rels`. + +--- + +## The Four Comment Files + +### 1. `word/comments.xml` — Main Comment Content + +Contains the actual comment text: + +```xml + + + + + + + + + + + This needs clarification. + + + + +``` + +Key attributes: `w:id` (unique integer), `w:author`, `w:date` (ISO 8601), `w:initials`. + +### 2. `word/commentsExtended.xml` — W15 Extensions + +Links comments to paragraphs and tracks resolved status: + +```xml + + + + +``` + +- `w15:paraId` — matches the `w14:paraId` of the comment's paragraph in `comments.xml` +- `w15:done` — `"0"` = open, `"1"` = resolved + +### 3. `word/commentsIds.xml` — Persistent ID Mapping + +Provides durable IDs that survive copy/paste across documents: + +```xml + + + + +``` + +- `w16cid:paraId` — same as `w15:paraId` +- `w16cid:durableId` — globally unique identifier (8-digit hex) + +### 4. `word/commentsExtensible.xml` — W16 Extensions + +Modern comment extensions (used in newer Word versions): + +```xml + + + + +``` + +--- + +## Document.xml References + +Comments are anchored in document content using three elements: + +```xml + + + This text has a comment. + + + + + + +``` + +- `w:commentRangeStart` — marks where the commented text begins +- `w:commentRangeEnd` — marks where the commented text ends +- `w:commentReference` — the visible comment marker (superscript number), placed in a run after the range end + +The `w:id` on all three must match the `w:id` in `comments.xml`. + +--- + +## Content Types Registration + +Add to `[Content_Types].xml`: + +```xml + + + + +``` + +--- + +## Relationship Registration + +Add to `word/_rels/document.xml.rels`: + +```xml + + + + +``` + +--- + +## Step-by-Step: Adding a New Comment + +1. **Choose a unique comment ID** (scan existing `w:id` values, use max + 1) +2. **Generate a paraId** (8-character hex, e.g., `"1A2B3C4D"`) and durableId (8-digit hex) +3. **Add to `comments.xml`**: Create `w:comment` element with content +4. **Add to `commentsExtended.xml`**: Create `w15:commentEx` with `paraId`, `done="0"` +5. **Add to `commentsIds.xml`**: Create `w16cid:commentId` with `paraId` and `durableId` +6. **Add to `commentsExtensible.xml`**: Create `w16cex:commentExtensible` with `durableId` and `dateUtc` +7. **Add to `document.xml`**: Insert `w:commentRangeStart`, `w:commentRangeEnd`, and `w:commentReference` around target text +8. **Verify `[Content_Types].xml`** and `document.xml.rels` have entries for all 4 files + +--- + +## Step-by-Step: Adding a Reply + +Replies are comments whose paragraph's `w14:paraId` links to a parent comment: + +1. Create a new `w:comment` in `comments.xml` with a new `w:id` +2. In `commentsExtended.xml`, add `w15:commentEx` with: + - `w15:paraId` = new paragraph ID + - `w15:paraIdParent` = the `paraId` of the comment being replied to + - `w15:done="0"` +3. Add entries in `commentsIds.xml` and `commentsExtensible.xml` +4. In `document.xml`, the reply does NOT need its own range markers — it shares the parent's range + +```xml + + +``` + +--- + +## Step-by-Step: Resolving a Comment + +Set `w15:done="1"` on the comment's `w15:commentEx` entry: + +```xml + + + + + +``` + +This marks the comment (and all its replies) as resolved. The comment remains visible but appears grayed out in Word. + +--- + +## Minimum Viable Comment + +At minimum, a working comment requires: +1. `comments.xml` with the `w:comment` element +2. `document.xml` with range markers and reference +3. Relationship in `document.xml.rels` +4. Content type in `[Content_Types].xml` + +The extended files (`commentsExtended`, `commentsIds`, `commentsExtensible`) are optional but recommended for full compatibility with modern Word. diff --git a/backend/app/skills_builtin/minimax-docx/references/design_good_bad_examples.md b/backend/app/skills_builtin/minimax-docx/references/design_good_bad_examples.md new file mode 100644 index 0000000..82b7c50 --- /dev/null +++ b/backend/app/skills_builtin/minimax-docx/references/design_good_bad_examples.md @@ -0,0 +1,829 @@ +# GOOD vs BAD Document Design — Concrete OpenXML Examples + +A side-by-side reference showing common design mistakes and their fixes, with exact OpenXML parameter values. Use this to develop an intuitive sense of what makes a document look professional versus amateur. + +Format: Each comparison shows the **BAD** version first (the mistake), then the **GOOD** version (the fix), with OpenXML markup and a short explanation. + +--- + +## 1. Font Size Disasters + +### 1a. No Hierarchy — Everything the Same Size + +**BAD: Body=12pt, H1=12pt bold** +``` +┌──────────────────────────────────┐ +│ INTRODUCTION │ ← 12pt bold... same visual weight +│ This is the body text of the │ ← 12pt regular +│ report. It discusses findings │ +│ from the quarterly review. │ +│ METHODOLOGY │ ← Where does the section start? +│ We collected data from three │ +│ sources across the enterprise. │ +└──────────────────────────────────┘ +``` +```xml + + + + +``` + +**GOOD: Modular scale — body=11pt, H3=13pt, H2=16pt, H1=20pt** +``` +┌──────────────────────────────────┐ +│ │ +│ Introduction │ ← 20pt, clearly a title +│ │ +│ This is the body text of the │ ← 11pt, comfortable reading size +│ report. It discusses findings │ +│ from the quarterly review. │ +│ │ +│ Methodology │ ← 20pt, section break is obvious +│ │ +│ We collected data from three │ +│ sources across the enterprise. │ +└──────────────────────────────────┘ +``` +```xml + + + + + + + + +``` +**Why better:** A clear size progression (ratio ~1.25x per step) lets readers instantly identify structure without reading a word. + +--- + +### 1b. Too Much Contrast — Children's Book Look + +**BAD: H1=28pt with body=10pt (ratio 2.8x)** +``` +┌──────────────────────────────────┐ +│ │ +│ QUARTERLY REPORT │ ← 28pt, dominates the page +│ │ +│ This is body text set very small │ ← 10pt, straining to read +│ and the contrast with the title │ +│ makes it feel like a poster. │ +└──────────────────────────────────┘ +``` +```xml + + +``` + +**GOOD: H1=20pt with body=11pt (ratio ~1.8x)** +```xml + + +``` +**Why better:** A heading-to-body ratio between 1.5x and 2.0x reads as "structured" rather than "shouting." + +--- + +## 2. Spacing Crimes + +### 2a. Wall of Text — No Paragraph or Line Spacing + +**BAD: Single line spacing, 0pt between paragraphs** +``` +┌──────────────────────────────────┐ +│The findings indicate a strong │ +│correlation between training hours│ +│and performance metrics. │ +│Further analysis revealed that │ ← No gap — where does the new +│departments with higher budgets │ paragraph start? +│achieved better outcomes in all │ +│measured categories. │ +└──────────────────────────────────┘ +``` +```xml + + + + +``` + +**GOOD: 1.15x line spacing, 8pt after each paragraph** +``` +┌──────────────────────────────────┐ +│The findings indicate a strong │ +│correlation between training │ ← Slightly more air between lines +│hours and performance metrics. │ +│ │ ← 8pt gap signals new paragraph +│Further analysis revealed that │ +│departments with higher budgets │ +│achieved better outcomes in all │ +│measured categories. │ +└──────────────────────────────────┘ +``` +```xml + + + + +``` +**Why better:** Line spacing gives each line room to breathe; paragraph spacing separates ideas without wasting a full blank line. + +--- + +### 2b. Floating Headings — Same Space Above and Below + +**BAD: 12pt before and 12pt after heading** +``` +┌──────────────────────────────────┐ +│ ...end of previous section. │ +│ │ ← 12pt gap +│ Section Two │ ← Heading floats in the middle +│ │ ← 12pt gap +│ Start of section two content. │ +└──────────────────────────────────┘ +``` +```xml + + + +``` + +**GOOD: 24pt before, 8pt after heading** +``` +┌──────────────────────────────────┐ +│ ...end of previous section. │ +│ │ +│ │ ← 24pt gap — clear section break +│ Section Two │ ← Heading is close to its content +│ │ ← 8pt gap +│ Start of section two content. │ +└──────────────────────────────────┘ +``` +```xml + + + +``` +**Why better:** Proximity principle: a heading belongs to the text that follows it, so more space above and less space below anchors it to its content. + +--- + +### 2c. Wasteful Gaps — Huge Spacing Everywhere + +**BAD: 24pt after every paragraph, including body text** +``` +┌──────────────────────────────────┐ +│ First paragraph of text here. │ +│ │ +│ │ ← 24pt gap after every paragraph +│ │ +│ Second paragraph of text here. │ +│ │ +│ │ +│ │ +│ Third paragraph. │ ← Document looks mostly white space +└──────────────────────────────────┘ +``` +```xml + +``` + +**GOOD: Proportional spacing — body=8pt, H2=6pt after, H1=10pt after** +```xml + + + + + + +``` +**Why better:** Spacing should vary by element role, creating a visual rhythm rather than uniform gaps. + +--- + +## 3. Margin Mistakes + +### 3a. Cramped Margins — Text Running to the Edge + +**BAD: 0.5in margins all around** +``` +┌────────────────────────────────────────────────┐ +│Text starts almost at the paper edge and runs │ +│all the way across making extremely long lines │ +│that are hard to track from end back to start. │ +│The eye loses its place on every line return. │ +└────────────────────────────────────────────────┘ +``` +```xml + + +``` + +**GOOD: 1in margins (standard)** +```xml + + +``` +**Why better:** Optimal line length is 60-75 characters. At 11pt Calibri, 6.5in width achieves roughly 70 characters per line. + +--- + +### 3b. Over-Padded Margins — Looks Like the Content is Hiding + +**BAD: 2in margins on a short document** +```xml + + +``` + +**GOOD: 1in standard, or 1.25in for formal documents** +```xml + + + + + +``` +**Why better:** Margins should frame the content, not overwhelm it. 1-1.25in works for virtually all business and academic documents. + +--- + +## 4. Table Ugliness + +### 4a. Prison Grid — Full Borders on Every Cell + +**BAD: Every cell with 1pt borders on all four sides** +``` +┌───────┬───────┬───────┬───────┐ +│ Name │ Dept │ Score │ Grade │ +├───────┼───────┼───────┼───────┤ +│ Alice │ Eng │ 92 │ A │ +├───────┼───────┼───────┼───────┤ +│ Bob │ Sales │ 85 │ B │ +├───────┼───────┼───────┼───────┤ +│ Carol │ Eng │ 78 │ C+ │ +└───────┴───────┴───────┴───────┘ +``` +```xml + + + + + + +``` + +**GOOD: Three-line table (三线表) — top thick, header-bottom medium, table-bottom thick** +``` +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ← 1.5pt top border + Name Dept Score Grade +────────────────────────────────── ← 0.75pt header separator + Alice Eng 92 A + Bob Sales 85 B + Carol Eng 78 C+ +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ← 1.5pt bottom border +``` +```xml + + + + + + + + + + + +``` +**Why better:** Removing inner borders lets the eye scan data freely. Three lines provide structure without visual clutter. + +--- + +### 4b. Text Touching Borders — No Cell Padding + +**BAD: Zero cell margins** +``` +┌──────────┬──────────┐ +│Name │Department│ ← Text cramped against borders +├──────────┼──────────┤ +│Alice │Engineering│ +└──────────┴──────────┘ +``` +```xml + + + + + + +``` + +**GOOD: 0.08in vertical, 0.12in horizontal padding** +```xml + + + + + + +``` +**Why better:** Padding gives text breathing room inside cells, making every value easier to read. + +--- + +### 4c. Invisible Headers — Header Row Same Style as Data + +**BAD: Header row indistinguishable from data** +```xml + + +``` + +**GOOD: Bold header text, subtle background fill, bottom border** +```xml + + + + + + + + + + + + + +``` +**Why better:** Distinct header styling lets readers instantly locate column meanings, especially in long tables that span pages. The `w:tblHeader` element ensures the header row repeats on every page. + +--- + +## 5. Font Pairing Failures + +### 5a. Visual Chaos — Too Many Fonts + +**BAD: 4+ fonts in one document** +```xml + + + + + + + + +``` + +**GOOD: One font family with weight variation, or two complementary families** +```xml + + + + + + + + +``` +**Why better:** Limiting to one or two font families creates visual coherence. Vary by size and weight, not by font. + +--- + +### 5b. Mismatched Personality — Comic Sans Meets Times New Roman + +**BAD:** +```xml + + +``` + +**GOOD: Fonts with compatible character** +```xml + + +``` +**Why better:** Paired fonts should share a similar level of formality and geometric character. Comic Sans is playful/informal; Times New Roman is formal/traditional. They clash. + +--- + +### 5c. Everything Bold — Nothing Stands Out + +**BAD: Bold on body, headings, captions, everything** +```xml + + + +``` + +**GOOD: Bold reserved for headings and key terms only** +```xml + + + + + +``` +**Why better:** When everything is emphasized, nothing is emphasized. Bold should be a signal, not a default. + +--- + +## 6. Color Abuse + +### 6a. Rainbow Headings + +**BAD: Each heading level a different bright color** +```xml + + + +``` + +**GOOD: Single accent color for headings, black or dark gray for body** +```xml + + + + + + +``` +**Why better:** A single accent color establishes brand consistency. Multiple bright colors compete for attention and look unprofessional. + +--- + +### 6b. Low Contrast — Light Gray on White + +**BAD: #CCCCCC text on white background** +```xml + + +``` + +**GOOD: #333333 text on white** +```xml + + +``` +**Why better:** Sufficient contrast is not just an accessibility requirement; it makes text physically easier to read for everyone, especially in printed documents. + +--- + +### 6c. Bright Body Text + +**BAD: Body text in a saturated color** +```xml + +``` + +**GOOD: Color reserved for headings and inline accents only** +```xml + + + + +``` +**Why better:** Colored body text causes eye fatigue over long reading. Reserve color for elements that need to attract attention (headings, links, warnings). + +--- + +## 7. List Formatting Issues + +### 7a. Bullet at the Margin — No Indent + +**BAD: List items start at the left margin** +``` +┌──────────────────────────────────┐ +│Here is a paragraph of text. │ +│• First item │ ← Bullet at margin, no indent +│• Second item │ +│• Third item │ +│Next paragraph continues here. │ +└──────────────────────────────────┘ +``` +```xml + + + +``` + +**GOOD: 0.25in left indent with hanging indent for the bullet** +``` +┌──────────────────────────────────┐ +│Here is a paragraph of text. │ +│ • First item │ ← Indented, clearly a list +│ • Second item │ +│ • Third item │ +│Next paragraph continues here. │ +└──────────────────────────────────┘ +``` +```xml + + + + + + + +``` +For nested lists, increment by 360 twips per level: +```xml + + + + +``` +**Why better:** Indentation visually separates lists from body text and makes nesting levels clear. + +--- + +### 7b. List Items with Full Paragraph Spacing + +**BAD: List items have the same 8-10pt spacing as body paragraphs** +``` +┌──────────────────────────────────┐ +│ • First item │ +│ │ ← 10pt gap — looks like separate +│ • Second item │ paragraphs, not a list +│ │ +│ • Third item │ +└──────────────────────────────────┘ +``` +```xml + +``` + +**GOOD: Tight spacing between list items (2-4pt)** +``` +┌──────────────────────────────────┐ +│ • First item │ +│ • Second item │ ← 2pt gap — cohesive list +│ • Third item │ +└──────────────────────────────────┘ +``` +```xml + + + +``` +**Why better:** Tight spacing groups list items as a single unit, matching how readers expect a list to behave. + +--- + +## 8. Header/Footer Problems + +### 8a. Header Text Too Large — Competes with Body + +**BAD: Header in 12pt, same as body** +``` +┌──────────────────────────────────┐ +│ Quarterly Report - Q3 2025 │ ← 12pt header, same as body +│──────────────────────────────────│ +│ Introduction │ +│ This is the body text... │ ← 12pt body — header distracts +└──────────────────────────────────┘ +``` +```xml + + +``` + +**GOOD: Header in 9pt, gray color, subtle** +``` +┌──────────────────────────────────┐ +│ Quarterly Report - Q3 2025 │ ← 9pt, gray — present but quiet +│──────────────────────────────────│ +│ Introduction │ +│ This is the body text... │ ← Body stands out as primary +└──────────────────────────────────┘ +``` +```xml + + + + + + + + + + +``` +**Why better:** Headers are reference information, not primary content. They should be legible but visually subordinate. + +--- + +### 8b. No Page Numbers on a Long Document + +**BAD: 20-page document with no page numbers** +```xml + +``` + +**GOOD: Page numbers in footer, right-aligned or centered** +```xml + + + + + + + + + + + + PAGE + + + + + + 1 + + + + + +``` +**Why better:** Page numbers are essential for navigation in any document over ~3 pages. Readers need to reference specific pages, and printed documents need an ordering mechanism. + +--- + +## 9. CJK-Specific Mistakes + +### 9a. Using Italic for Chinese Emphasis + +**BAD: Italic applied to Chinese text** +```xml + + + + + +``` +CJK glyphs have no true italic form. The renderer applies a synthetic slant that looks broken and ugly — characters appear to lean awkwardly. + +**GOOD: Use bold or emphasis dots (着重号) for Chinese emphasis** +```xml + + + + + + + + + + + + + +``` +**Why better:** Chinese typography has its own emphasis traditions. Bold and emphasis dots are native CJK conventions; italic is a Latin-script concept that does not translate. + +--- + +### 9b. Latin Font for Chinese Characters + +**BAD: Only ASCII font set, no EastAsia font specified** +```xml + + + + + +``` + +**GOOD: Explicit EastAsia font alongside ASCII font** +```xml + + + + +``` +For formal/academic Chinese documents: +```xml + + + + +``` +**Why better:** Setting `w:eastAsia` ensures Chinese characters render in a font designed for CJK glyphs, with correct stroke widths, spacing, and metrics. + +--- + +### 9c. English Line Spacing for Dense CJK Text + +**BAD: 1.15x line spacing for Chinese body text** +```xml + +``` +CJK characters are taller and denser than Latin letters. At 1.15x, lines of Chinese text feel cramped and hard to read. + +**GOOD: 1.5x line spacing or fixed 28pt for CJK body at 12pt (小四)** +```xml + + + + + +``` +For 公文 (government documents) at 三号/16pt body: +```xml + +``` +**Why better:** CJK characters occupy a full em square with no ascenders/descenders providing natural gaps. Extra line spacing compensates, improving readability of dense text blocks. + +--- + +## 10. Overall Document Feel + +### Student Homework vs Professional Document + +**BAD: "Student homework" — every setting is Word's default, no intentional choices** +```xml + + + + + + + + +``` + +**GOOD: Intentional design at every level** +```xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +``` +**Why better:** Professional documents result from deliberate, consistent choices across all design dimensions. Each element reinforces the same visual language. The reader may not consciously notice good typography, but they feel the difference in credibility and readability. + +--- + +## Quick Reference: Safe Defaults + +A cheat sheet of values that produce a professional result for most Western business documents: + +| Element | Value | OpenXML | +|---------|-------|---------| +| Body font | Calibri 11pt | `w:sz="22"` | +| H1 | Calibri Light 20pt | `w:sz="40"` | +| H2 | Calibri Light 16pt | `w:sz="32"` | +| H3 | Calibri 13pt bold | `w:sz="26"`, `w:b` | +| Body color | #333333 | `w:color="333333"` | +| Heading color | #1F4E79 | `w:color="1F4E79"` | +| Line spacing | 1.15x | `w:line="276" w:lineRule="auto"` | +| Para spacing after | 8pt | `w:after="160"` | +| H1 spacing | 24pt before, 10pt after | `w:before="480" w:after="200"` | +| H2 spacing | 16pt before, 6pt after | `w:before="320" w:after="120"` | +| Margins | 1in all around | `w:pgMar` all `"1440"` | +| Table cell padding | 0.08in / 0.12in | `w:w="115"` / `w:w="173"` | +| Header/footer size | 9pt gray | `w:sz="18" w:color="808080"` | +| List indent | 0.25in per level | `w:left="360" w:hanging="360"` | +| List item spacing | 2pt after | `w:after="40"` | + +For CJK documents, adjust: body font to SimSun/YaHei, line spacing to 1.5x (`w:line="360"`), and set `w:eastAsia` on all `w:rFonts`. diff --git a/backend/app/skills_builtin/minimax-docx/references/design_principles.md b/backend/app/skills_builtin/minimax-docx/references/design_principles.md new file mode 100644 index 0000000..6d81dd3 --- /dev/null +++ b/backend/app/skills_builtin/minimax-docx/references/design_principles.md @@ -0,0 +1,819 @@ +# Design Principles for Document Typography + +WHY certain typographic choices look good -- the perceptual and psychological +reasons behind professional document design. Use this to make judgment calls +when exact specs are not provided. + +## Table of Contents + +1. [White Space & Breathing Room](#1-white-space--breathing-room) +2. [Contrast & Scale](#2-contrast--scale) +3. [Proximity & Grouping](#3-proximity--grouping) +4. [Alignment & Grid](#4-alignment--grid) +5. [Repetition & Consistency](#5-repetition--consistency) +6. [Visual Hierarchy & Flow](#6-visual-hierarchy--flow) + +--- + +## 1. White Space & Breathing Room + +### Why It Works + +The human eye does not read continuously. It jumps in saccades, fixating on +small clusters of words. White space provides landing zones for these fixations +and gives the reader's peripheral vision a "frame" that makes each text block +feel manageable. When a page is packed to the edges, every glance returns more +text than working memory can buffer, triggering fatigue and avoidance. + +Research on content density consistently shows: + +- **60-70% content coverage** feels comfortable and professional. +- **80%+** starts to feel dense and bureaucratic. +- **90%+** feels oppressive -- the reader unconsciously rushes or skips. +- **Below 50%** feels wasteful or pretentious (unless intentional, like poetry). + +Wider margins also carry cultural signals. Academic and luxury documents use +generous margins (1.25-1.5 inches). Internal memos and drafts use narrower +margins (0.75-1.0 inches). The margin width tells the reader how much care +went into the document before they read a single word. + +Line spacing has a direct physiological basis: the eye must track back to the +start of the next line after each line break. If lines are too close, the eye +"slips" to the wrong line. If too far apart, the eye loses its sense of +continuity. The sweet spot is 120-145% of the font size. + +**Rule of thumb: when in doubt, add more space, not less.** + +### Good Example + +``` +Margins: 1 inch (1440 twips) all sides for business documents. +Line spacing: 1.15 (276 twips at 240 twips-per-line = 115%). +Paragraph spacing after: 8pt (160 twips) between body paragraphs. +``` + +```xml + + + + + + + +``` + +This produces a page where content occupies roughly 65% of the area. The +reader sees clear top/bottom breathing room, and paragraphs are distinct +without feeling disconnected. + +``` + Page layout (good): + +----------------------------------+ + | 1" margin | + | +------------------------+ | + | | Heading | | + | | | | + | | Body text here with | | + | | comfortable spacing | | + | | between lines. | | + | | | | <- visible gap between paragraphs + | | Another paragraph of | | + | | body text follows. | | + | | | | + | +------------------------+ | + | 1" margin | + +----------------------------------+ +``` + +### Bad Example + +```xml + + + + + + + +``` + +This fills ~85% of the page. Text runs edge-to-edge with no visual rest stops. +The reader sees a wall of text. + +``` + Page layout (bad): + +----------------------------------+ + | Heading | + | Body text crammed right up to | + | the margins with no spacing | + | between lines or paragraphs. | + | Another paragraph starts here | + | and the reader cannot tell where | + | one idea ends and another begins | + | because everything blurs into a | + | single dense block of text. | + +----------------------------------+ +``` + +### Quick Test + +1. Zoom out to 50% in your document viewer. If you cannot see clear "channels" + of white between text blocks, the spacing is too tight. +2. Print a test page. Hold it at arm's length. The text area should look like + a rectangle floating in white, not filling the page. +3. Check: is the line spacing value at least 264 (`w:line` for 1.1x) for body + text? If it is 240 (single), it is too tight for anything over 10pt. + +--- + +## 2. Contrast & Scale + +### Why It Works + +The brain processes visual hierarchy through relative difference, not absolute +size. A 20pt heading above 11pt body text creates a clear "this is important" +signal. But if every heading is 20pt and every sub-heading is 19pt, the brain +cannot distinguish them -- they merge into the same level. + +The key insight is **modular scale**: font sizes that grow by a consistent +ratio. This mirrors natural proportions and feels harmonious for the same +reason musical intervals do. + +Common scales and their character: + +| Ratio | Name | Character | Example progression (from 11pt) | +|-------|----------------|---------------------------------|---------------------------------| +| 1.200 | Minor third | Subtle, refined | 11 → 13.2 → 15.8 → 19.0 | +| 1.250 | Major third | Balanced, professional | 11 → 13.75 → 17.2 → 21.5 | +| 1.333 | Perfect fourth | Strong, authoritative | 11 → 14.7 → 19.5 → 26.0 | +| 1.414 | Augmented 4th | Dramatic, presentation-style | 11 → 15.6 → 22.0 → 31.1 | + +For most business documents, 1.25 (major third) works best: + +``` +Body = 11pt (w:sz="22") +H3 = 13pt (w:sz="26") -- 11 * 1.25 ≈ 13.75, round to 13 +H2 = 16pt (w:sz="32") -- 13 * 1.25 ≈ 16.25, round to 16 +H1 = 20pt (w:sz="40") -- 16 * 1.25 = 20 +``` + +Beyond size, **weight contrast** creates hierarchy without consuming vertical +space. Regular (400) vs Bold (700) is visible at any size. Semi-bold (600) vs +Regular is subtle and best avoided unless you also vary size or color. + +**Color contrast** adds a third dimension. Dark blue headings (#1F3864) against +softer dark gray body text (#333333) signals "heading" without needing a huge +size jump. Pure black (#000000) body text is harsher than necessary on white +backgrounds -- #333333 or #2D2D2D reduces glare without losing legibility. + +### Good Example + +```xml + + + + + + + + + + + + + + + + + + + + + + + + + + +``` + +``` + Visual hierarchy (good): + + [████████████████████] <- H1: 20pt bold navy (clearly dominant) + (generous space) + [██████████████] <- H2: 16pt bold navy (distinct step down) + (moderate space) + [████████████] <- H3: 13pt bold navy (smaller but still bold) + [░░░░░░░░░░░░░░░░░░░░░░] <- Body: 11pt regular gray + [░░░░░░░░░░░░░░░░░░░░░░] + [░░░░░░░░░░░░░░░░░░░░░░] +``` + +Each level is visually distinct from its neighbors. You can identify the +hierarchy even in peripheral vision. + +### Bad Example + +```xml + + + + + + + + + + + + + + + + + + + + + + + + + + +``` + +Problems: +- H3 (12pt bold) and body (12pt regular) differ only by weight -- too subtle. +- H1 (14pt) to H2 (13pt) is a 1pt step -- invisible at reading distance. +- Everything is pure black so color provides no differentiating signal. +- The ratio between levels is ~1.07, far too flat. + +### Quick Test + +1. **The squint test**: blur your eyes or step back from the screen. Can you + count the number of heading levels? If two levels merge, their contrast + is insufficient. +2. **Ratio check**: divide each heading size by the next smaller size. If any + ratio is below 1.15, the levels will look too similar. +3. **Color check**: do headings look distinct from body text when you glance + at the page? If everything is the same color, you are relying solely on + size/weight, which limits your hierarchy to ~3 effective levels. + +--- + +## 3. Proximity & Grouping + +### Why It Works + +The Gestalt principle of proximity: items that are close together are perceived +as belonging to the same group. In document typography, this means a heading +must be **closer to the content it introduces** than to the content above it. + +If a heading sits equidistant between two paragraphs, it looks orphaned -- the +reader's eye does not know if it belongs to the text above or below. The fix +is asymmetric spacing: **large space before the heading, small space after**. + +The recommended ratio is 2:1 or 3:1 (space-before : space-after). + +This same principle applies to: +- **List items**: spacing between items should be less than spacing between + paragraphs. Items in a list are a group and should visually cluster. +- **Captions**: a figure caption should be close to its figure, not floating + in the middle between the figure and the next paragraph. +- **Table titles**: the title sits close above the table, with more space + separating the title from preceding text. + +### Good Example + +```xml + + + + + + + + + + + + + + + + +``` + +``` + Proximity (good): + + ...end of previous section text. + <- 18pt gap (w:before="360") + ## Section Heading + <- 6pt gap (w:after="120") + First paragraph of new section + continues here with content. + <- 8pt gap (w:after="160") + Second paragraph follows. + + The heading clearly "belongs to" the text below it. +``` + +``` + List grouping (good): + + Consider these factors: + - First item <- 2pt gap between items + - Second item <- items cluster as a group + - Third item + <- 8pt gap after list + The next paragraph starts here. +``` + +### Bad Example + +```xml + + + + + + + + + + + +``` + +``` + Proximity (bad): + + ...end of previous section text. + <- 12pt gap + ## Section Heading + <- 12pt gap (same!) + First paragraph of new section. + + The heading floats between sections. It is unclear what it belongs to. +``` + +``` + List grouping (bad): + + Consider these factors: + <- 10pt gap + - First item + <- 10pt gap (same as paragraphs) + - Second item + <- 10pt gap + - Third item + <- 10pt gap + Next paragraph. + + The list does not feel like a group. Each item looks like a + separate paragraph that happens to have a bullet. +``` + +### Quick Test + +1. **Cover test**: cover the heading text. Looking only at the whitespace, + can you tell which block of text the heading belongs to? If the gaps above + and below are equal, the answer is "no." +2. **Number check**: `w:before` on headings should be at least 2x `w:after`. + Common good values: before=360 / after=120, or before=240 / after=80. +3. **List check**: `w:after` on list items should be less than half of + `w:after` on body paragraphs. If body uses 160, list items should use + 40-60. + +--- + +## 4. Alignment & Grid + +### Why It Works + +Alignment creates invisible lines that the eye follows down the page. When +elements share the same left edge, the reader perceives order and intention. +When elements are slightly misaligned (off by a few twips), the page looks +sloppy even if the reader cannot consciously identify why. + +**Left-align vs Justify:** + +- **Left-aligned** (ragged right) is best for English and other Latin-script + languages. The uneven right edge actually helps reading because each line + has a unique silhouette, making it easier for the eye to find the next line. + Justified text forces uneven word spacing that creates distracting "rivers" + of white running vertically through paragraphs. + +- **Justified** is best for CJK text. Chinese, Japanese, and Korean characters + are monospaced by design -- each occupies the same cell in an invisible grid. + Justification preserves this grid perfectly. Ragged right in CJK text breaks + the grid and looks untidy. + +**Indentation rule:** Use first-line indent OR paragraph spacing to separate +paragraphs -- never both. They serve the same purpose (marking paragraph +boundaries). Using both wastes space and creates visual stutter. + +- Western convention: paragraph spacing (no indent) is more modern. +- CJK convention: first-line indent of 2 characters is standard. +- Academic convention: first-line indent of 0.5 inch is traditional. + +### Good Example + +```xml + + + + + + + + + + + + + + + + + + + + + +``` + +``` + English paragraph separation (good -- spacing, no indent): + + This is the first paragraph with some text + that wraps to a second line naturally. + + This is the second paragraph. The gap above + clearly marks the boundary. + + + CJK paragraph separation (good -- indent, no spacing): + +   第一段正文内容从这里开始,使用两个字符 + 的首行缩进来标记段落边界。 +   第二段紧跟其后,没有段间距,但首行缩进 + 清晰地标识了新段落的开始。 +``` + +### Bad Example + +```xml + + + + + + + + + + + + + +``` + +Problems: +- Justified English text with narrow columns creates uneven word gaps. +- Using both first-line indent AND paragraph spacing is redundant. +- Left-aligned CJK breaks the character grid that CJK readers expect. +- CJK with spacing-based separation looks like translated western layout. + +### Quick Test + +1. **River test**: in justified English text, squint and look for vertical + white streaks running through the paragraph. If you see them, switch to + left-align or increase the column width. +2. **Double signal check**: does the document use BOTH first-line indent AND + paragraph spacing? If yes, remove one. Choose indent for CJK/academic, + spacing for modern western. +3. **Tab alignment**: if you use tabs for columns, do all tab stops across + the document use the same positions? Inconsistent tab stops create jagged + invisible grid lines. + +--- + +## 5. Repetition & Consistency + +### Why It Works + +Consistency is a trust signal. When a reader sees that every H2 looks the same, +every table follows the same pattern, and every page number sits in the same +spot, they unconsciously trust that the document was crafted with care. A single +inconsistency -- one H2 that is 15pt instead of 14pt, one table with different +borders -- breaks that trust and makes the reader question the content. + +Consistency also reduces cognitive load. Once the reader learns "bold dark blue += section heading," they stop spending mental effort on identifying structure +and focus entirely on content. Every inconsistency forces them to re-evaluate: +"Is this a different kind of heading, or did someone just forget to apply the +style?" + +The implementation rule is simple: **use named styles, not direct formatting.** +If you define Heading2 as a style and apply it everywhere, consistency is +automatic. If you manually set font size, bold, and color on each heading +individually, inconsistency is inevitable. + +### Good Example + +```xml + + + + + + + + + + + + + + + + + + + + + + + + + + Market Analysis + +``` + +When using a table style, define it once and reference it for every table: + +```xml + + + + + +``` + +### Bad Example + +```xml + + + + + + + + + + + + Market Analysis + + + + + + + + + + + + + + + Financial Overview + + +``` + +Problems: +- No style references -- everything is direct formatting. +- Second H2 has different size (30 vs 32), color, and spacing. +- If there are 20 headings, each could drift slightly differently. +- Changing the design later means editing every heading individually. + +### Quick Test + +1. **Style audit**: does every paragraph reference a `w:pStyle`? If you find + paragraphs with only direct formatting and no style, that is a consistency + risk. +2. **Search for variance**: search the XML for all `w:sz` values used with + `w:b` (bold). If you find three different sizes for what should be the same + heading level, there is an inconsistency. +3. **Table check**: do all tables in the document reference the same + `w:tblStyle`? If some tables have manual border definitions while others + use a style, the document will look patchy. +4. **Page numbers**: check that header/footer content is defined in the + default section properties and inherited by all sections, not redefined + inconsistently in each section. + +--- + +## 6. Visual Hierarchy & Flow + +### Why It Works + +A well-designed document guides the reader's eye in a predictable path: +title at the top, subtitle below it, section headings as signposts, body text +as the main content, footnotes and captions as supporting details. This flow +mirrors reading priority -- the most important information is the most visually +prominent. + +Each level in the hierarchy must be **distinguishable from its adjacent +levels**. It is not enough for H1 to differ from body text; H1 must also +clearly differ from H2, and H2 from H3. If any two adjacent levels are too +similar, the hierarchy collapses at that point. + +Effective hierarchy uses **multiple simultaneous signals**: + +| Level | Size | Weight | Color | Spacing above | +|----------|-------|---------|---------|---------------| +| Title | 26pt | Bold | #1F3864 | 0 (top) | +| Subtitle | 15pt | Regular | #4472C4 | 4pt | +| H1 | 20pt | Bold | #1F3864 | 24pt | +| H2 | 16pt | Bold | #1F3864 | 18pt | +| H3 | 13pt | Bold | #1F3864 | 12pt | +| Body | 11pt | Regular | #333333 | 0pt | +| Caption | 9pt | Italic | #666666 | 4pt | +| Footnote | 9pt | Regular | #666666 | 0pt | + +Notice how each level differs from its neighbors on at least two dimensions +(size + weight, or size + color, or weight + style). Single-dimension +differences are fragile and can be missed. + +**Section breaks** create rhythm in long documents. A page break before each +major section (H1) gives the reader a mental reset. Within sections, consistent +heading + body patterns create a predictable cadence that makes long documents +less intimidating. + +### Good Example + +```xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +``` + +``` + Visual flow (good): + + +----------------------------------+ + | | + | ANNUAL REPORT 2025 | <- Title: 26pt bold navy centered + | Acme Corporation | <- Subtitle: 15pt regular blue + | | + | | + +----------------------------------+ + + +----------------------------------+ + | | + | 1. Executive Summary | <- H1: 20pt bold navy (page break) + | | + | Body text introducing the | <- Body: 11pt regular gray + | main findings of the year. | + | | + | 1.1 Revenue Highlights | <- H2: 16pt bold navy + | | + | Revenue grew by 23% year | <- Body + | over year, driven by... | + | | + | Figure 1: Revenue Growth | <- Caption: 9pt italic gray + | | + +----------------------------------+ + + Each level is immediately identifiable. The eye flows naturally + from title -> heading -> body -> caption. +``` + +### Bad Example + +```xml + + + + + + + + + + + + + + + + + + + +``` + +Problems: +- H1 at 14pt is too close to body at 11pt (ratio 1.27 -- acceptable in + isolation but with black color matching body, the hierarchy is weak). +- Caption is indistinguishable from body text. +- No page breaks means major sections bleed into each other with no + visual rhythm. +- Everything is black, so color provides zero hierarchy signal. + +### Quick Test + +1. **The squint test**: blur your eyes while looking at a full page. You + should see 3-4 distinct "weight levels" of gray. If the page looks like + one uniform shade, the hierarchy is too flat. +2. **The scan test**: flip through pages quickly. Can you identify section + boundaries in under one second per page? If yes, the visual hierarchy is + working. If pages blur together, you need stronger differentiation at H1. +3. **Adjacent level test**: for each heading level, check that it differs + from the next level on at least 2 of: size, weight, color, style (italic). + Single-dimension differences get lost. +4. **Rhythm test**: in a document over 10 pages, do major sections (H1) start + on new pages? If not, long documents will feel like an undifferentiated + stream. Add `w:pageBreakBefore` to Heading1. + +--- + +## Summary: Decision Checklist + +When you are unsure about a typographic choice, run through these checks: + +| Principle | Question | If No... | +|-----------|----------|----------| +| White Space | Does the page have at least 30% white space? | Increase margins or spacing | +| Contrast | Can I count heading levels by squinting? | Increase size ratios (target 1.25x) | +| Proximity | Does each heading clearly belong to text below it? | Make space-before > space-after (2:1) | +| Alignment | Is English left-aligned and CJK justified? | Switch alignment mode | +| Repetition | Do all same-level elements use the same style? | Replace direct formatting with styles | +| Hierarchy | Can I see the document structure at arm's length? | Add more differentiation signals | + +**When two principles conflict, prioritize in this order:** + +1. **Readability** (white space, line spacing) -- always wins +2. **Hierarchy** (contrast, scale) -- readers must find what they need +3. **Consistency** (repetition) -- builds trust +4. **Aesthetics** (alignment, grouping) -- the finishing touch diff --git a/backend/app/skills_builtin/minimax-docx/references/openxml_element_order.md b/backend/app/skills_builtin/minimax-docx/references/openxml_element_order.md new file mode 100644 index 0000000..a84b5a2 --- /dev/null +++ b/backend/app/skills_builtin/minimax-docx/references/openxml_element_order.md @@ -0,0 +1,308 @@ +# OpenXML Child Element Ordering Rules + +Element ordering in OpenXML is defined by the XSD schema. Incorrect ordering produces invalid documents that Word may refuse to open or silently repair (potentially losing data). + +> **Key rule**: Properties elements (`*Pr`) must always be the **first child** of their parent. + +--- + +## w:document + +``` +Children in order: +1. w:background [0..1] — page background color/fill +2. w:body [0..1] — document content container +``` + +--- + +## w:body + +``` +Children in order (repeating group): +1. w:p [0..*] — paragraph +2. w:tbl [0..*] — table +3. w:sdt [0..*] — structured document tag (content control) +4. w:sectPr [0..1] — LAST child: final section properties +``` + +Note: `w:p`, `w:tbl`, and `w:sdt` are interleaved in document order. The only strict rule is that `w:sectPr` must be the **last child** of `w:body`. + +--- + +## w:p (Paragraph) + +``` +Children in order: +1. w:pPr [0..1] — paragraph properties (MUST be first) + +Then any mix of (interleaved in document order): +- w:r [0..*] — run +- w:hyperlink [0..*] — hyperlink wrapper +- w:ins [0..*] — tracked insertion +- w:del [0..*] — tracked deletion +- w:bookmarkStart [0..*] — bookmark anchor start +- w:bookmarkEnd [0..*] — bookmark anchor end +- w:commentRangeStart [0..*] — comment range start +- w:commentRangeEnd [0..*] — comment range end +- w:proofErr [0..*] — proofing error marker +- w:fldSimple [0..*] — simple field +- w:sdt [0..*] — inline content control +- w:smartTag [0..*] — smart tag +``` + +**Practical note**: After `w:pPr`, the remaining children appear in document reading order. Runs, hyperlinks, bookmarks, and comment ranges intermix freely based on their position in the text. + +--- + +## w:pPr (Paragraph Properties) + +``` +Children in order: +1. w:pStyle [0..1] — paragraph style reference +2. w:keepNext [0..1] — keep with next paragraph +3. w:keepLines [0..1] — keep lines together +4. w:pageBreakBefore [0..1] — page break before paragraph +5. w:framePr [0..1] — text frame properties +6. w:widowControl [0..1] — widow/orphan control +7. w:numPr [0..1] — numbering properties +8. w:suppressLineNumbers [0..1] +9. w:pBdr [0..1] — paragraph borders +10. w:shd [0..1] — shading +11. w:tabs [0..1] — tab stops +12. w:suppressAutoHyphens [0..1] +13. w:kinsoku [0..1] — CJK kinsoku settings +14. w:wordWrap [0..1] +15. w:overflowPunct [0..1] +16. w:topLinePunct [0..1] +17. w:autoSpaceDE [0..1] +18. w:autoSpaceDN [0..1] +19. w:bidi [0..1] — right-to-left paragraph +20. w:adjustRightInd [0..1] +21. w:snapToGrid [0..1] +22. w:spacing [0..1] — line and paragraph spacing +23. w:ind [0..1] — indentation +24. w:contextualSpacing [0..1] +25. w:mirrorIndents [0..1] +26. w:suppressOverlap [0..1] +27. w:jc [0..1] — justification (left/center/right/both) +28. w:textDirection [0..1] +29. w:textAlignment [0..1] +30. w:outlineLvl [0..1] — outline level +31. w:divId [0..1] +32. w:rPr [0..1] — run properties for paragraph mark +33. w:sectPr [0..1] — section break (section ends at this paragraph) +34. w:pPrChange [0..1] — tracked paragraph property change +``` + +--- + +## w:r (Run) + +``` +Children in order: +1. w:rPr [0..1] — run properties (MUST be first) + +Then any of (one per run, typically): +- w:t [0..*] — text content +- w:br [0..*] — break (line, page, column) +- w:tab [0..*] — tab character +- w:cr [0..*] — carriage return +- w:sym [0..*] — symbol character +- w:drawing [0..*] — DrawingML object (images) +- w:pict [0..*] — VML picture (legacy) +- w:fldChar [0..*] — complex field character +- w:instrText [0..*] — field instruction text +- w:delText [0..*] — deleted text (inside w:del) +- w:footnoteReference [0..*] +- w:endnoteReference [0..*] +- w:commentReference [0..*] +- w:lastRenderedPageBreak [0..*] +``` + +--- + +## w:rPr (Run Properties) + +``` +Children in order: +1. w:rStyle [0..1] — character style reference +2. w:rFonts [0..1] — font specification +3. w:b [0..1] — bold +4. w:bCs [0..1] — complex script bold +5. w:i [0..1] — italic +6. w:iCs [0..1] — complex script italic +7. w:caps [0..1] — all capitals +8. w:smallCaps [0..1] — small capitals +9. w:strike [0..1] — strikethrough +10. w:dstrike [0..1] — double strikethrough +11. w:outline [0..1] +12. w:shadow [0..1] +13. w:emboss [0..1] +14. w:imprint [0..1] +15. w:noProof [0..1] — suppress proofing +16. w:snapToGrid [0..1] +17. w:vanish [0..1] — hidden text +18. w:color [0..1] — text color +19. w:spacing [0..1] — character spacing +20. w:w [0..1] — character width scaling +21. w:kern [0..1] — font kerning +22. w:position [0..1] — vertical position (raise/lower) +23. w:sz [0..1] — font size (half-points) +24. w:szCs [0..1] — complex script font size +25. w:highlight [0..1] — text highlight color +26. w:u [0..1] — underline +27. w:effect [0..1] — text effect (animated) +28. w:bdr [0..1] — run border +29. w:shd [0..1] — run shading +30. w:vertAlign [0..1] — superscript/subscript +31. w:rtl [0..1] — right-to-left +32. w:cs [0..1] — complex script +33. w:lang [0..1] — language +34. w:rPrChange [0..1] — tracked run property change +``` + +--- + +## w:tbl (Table) + +``` +Children in order: +1. w:tblPr [1..1] — table properties (REQUIRED, must be first) +2. w:tblGrid [1..1] — column width definitions (REQUIRED) +3. w:tr [1..*] — table row(s) +``` + +--- + +## w:tblPr (Table Properties) + +``` +Children in order: +1. w:tblStyle [0..1] — table style reference +2. w:tblpPr [0..1] — table positioning +3. w:tblOverlap [0..1] +4. w:bidiVisual [0..1] — right-to-left table +5. w:tblStyleRowBandSize [0..1] +6. w:tblStyleColBandSize [0..1] +7. w:tblW [0..1] — preferred table width +8. w:jc [0..1] — table alignment +9. w:tblCellSpacing [0..1] +10. w:tblInd [0..1] — table indent from margin +11. w:tblBorders [0..1] — table borders +12. w:shd [0..1] — table shading +13. w:tblLayout [0..1] — fixed or autofit +14. w:tblCellMar [0..1] — default cell margins +15. w:tblLook [0..1] — conditional formatting flags +16. w:tblCaption [0..1] — accessibility caption +17. w:tblDescription [0..1] — accessibility description +18. w:tblPrChange [0..1] — tracked table property change +``` + +--- + +## w:tr (Table Row) + +``` +Children in order: +1. w:trPr [0..1] — row properties (must be first) +2. w:tc [1..*] — table cell(s) +``` + +--- + +## w:trPr (Table Row Properties) + +``` +Children in order: +1. w:cnfStyle [0..1] — conditional formatting +2. w:divId [0..1] +3. w:gridBefore [0..1] — grid columns before first cell +4. w:gridAfter [0..1] — grid columns after last cell +5. w:wBefore [0..1] +6. w:wAfter [0..1] +7. w:cantSplit [0..1] — don't split row across pages +8. w:trHeight [0..1] — row height +9. w:tblHeader [0..1] — repeat as header row +10. w:tblCellSpacing [0..1] +11. w:jc [0..1] — row alignment +12. w:hidden [0..1] +13. w:ins [0..1] — tracked row insertion +14. w:del [0..1] — tracked row deletion +15. w:trPrChange [0..1] — tracked row property change +``` + +--- + +## w:tc (Table Cell) + +``` +Children in order: +1. w:tcPr [0..1] — cell properties (must be first) +2. w:p [1..*] — paragraph(s) — at least one required +3. w:tbl [0..*] — nested table(s) +``` + +--- + +## w:tcPr (Table Cell Properties) + +``` +Children in order: +1. w:cnfStyle [0..1] +2. w:tcW [0..1] — cell width +3. w:gridSpan [0..1] — horizontal merge (column span) +4. w:hMerge [0..1] — legacy horizontal merge +5. w:vMerge [0..1] — vertical merge +6. w:tcBorders [0..1] — cell borders +7. w:shd [0..1] — cell shading +8. w:noWrap [0..1] +9. w:tcMar [0..1] — cell margins +10. w:textDirection [0..1] +11. w:tcFitText [0..1] +12. w:vAlign [0..1] — vertical alignment +13. w:hideMark [0..1] +14. w:tcPrChange [0..1] — tracked cell property change +``` + +--- + +## w:sectPr (Section Properties) + +``` +Children in order: +1. w:headerReference [0..*] — header references (type: default/first/even) +2. w:footerReference [0..*] — footer references +3. w:endnotePr [0..1] +4. w:footnotePr [0..1] +5. w:type [0..1] — section break type (nextPage/continuous/evenPage/oddPage) +6. w:pgSz [0..1] — page size +7. w:pgMar [0..1] — page margins +8. w:paperSrc [0..1] +9. w:pgBorders [0..1] — page borders +10. w:lnNumType [0..1] — line numbering +11. w:pgNumType [0..1] — page numbering +12. w:cols [0..1] — column definitions +13. w:formProt [0..1] +14. w:vAlign [0..1] — vertical alignment of page +15. w:noEndnote [0..1] +16. w:titlePg [0..1] — different first page header/footer +17. w:textDirection [0..1] +18. w:bidi [0..1] +19. w:rtlGutter [0..1] +20. w:docGrid [0..1] — document grid +21. w:sectPrChange [0..1] — tracked section property change +``` + +--- + +## w:hdr (Header) / w:ftr (Footer) + +``` +Children (same structure as w:body content): +1. w:p [0..*] — paragraph(s) +2. w:tbl [0..*] — table(s) +3. w:sdt [0..*] — content controls +``` + +Headers and footers are essentially mini-documents. They follow the same content model as `w:body` but without a final `w:sectPr`. diff --git a/backend/app/skills_builtin/minimax-docx/references/openxml_encyclopedia_part1.md b/backend/app/skills_builtin/minimax-docx/references/openxml_encyclopedia_part1.md new file mode 100644 index 0000000..177182f --- /dev/null +++ b/backend/app/skills_builtin/minimax-docx/references/openxml_encyclopedia_part1.md @@ -0,0 +1,4061 @@ +# OpenXML SDK 3.x Complete Reference Encyclopedia + +**Target:** DocumentFormat.OpenXml 3.x / .NET 8+ / C# 12 +**Last Updated:** 2026-03-22 + +This document serves as an exhaustive reference for building DOCX files with the OpenXML SDK. Every code block is ready to copy-paste. + +--- + +## Namespace Aliases Used Throughout + +```csharp +using DocumentFormat.OpenXml; +using DocumentFormat.OpenXml.Packaging; +using DocumentFormat.OpenXml.Wordprocessing; +``` + +--- + +## Table of Contents + +1. [Document Creation Skeleton](#1-document-creation-skeleton) +2. [Style System Deep Dive](#2-style-system-deep-dive) +3. [Character Formatting (RunProperties)](#3-character-formatting-runproperties--exhaustive) +4. [Paragraph Formatting (ParagraphProperties)](#4-paragraph-formatting-paragraphproperties--exhaustive) + +--- + +## 1. Document Creation Skeleton + +### 1.1 Complete Flow: Create to Save + +```csharp +// ============================================================================= +// DOCUMENT CREATION SKELETON +// ============================================================================= +// This is the minimal complete flow for creating a valid DOCX from scratch. +// Follow these steps in order: Create -> AddParts -> AddContent -> Save. +// +// Key insight: WordprocessingDocument.Create() adds MainDocumentPart automatically, +// but all other parts (Styles, Settings, Numbering, Theme) must be added manually. + +// --- STEP 1: CREATE THE PACKAGE --- +// The file path can be absolute or relative. WordprocessingDocumentType.Document +// is the standard choice for .docx files (vs. Template, MacroEnabled, etc.) +string outputPath = "C:\\Docs\\MyDocument.docx"; + +using var doc = WordprocessingDocument.Create( + outputPath, // File path + WordprocessingDocumentType.Document, // Document type enum + new DocumentOptions // Optional: AutoSave, etc. + { + AutoSave = false // true = flush changes automatically + }); + +// --- STEP 2: GET OR CREATE THE MAIN DOCUMENT PART --- +// When you call Create(), MainDocumentPart is automatically created and linked. +// You access it via .MainDocumentPart (not .AddMainDocumentPart, which would add +// a SECOND main part — illegal). For a fresh document, just use .MainDocumentPart. +var mainPart = doc.MainDocumentPart!; +var body = mainPart.Document.Body!; // Body is created automatically with the part + +// --- STEP 3: ADD ADDITIONAL PARTS --- +// These are OPTIONAL but recommended for a complete document: +// - StyleDefinitionsPart: required for styles +// - NumberingDefinitionsPart: required for bullets/numbers +// - DocumentSettingsPart: zoom, proof state, tab stops, compatibility +// - ThemePart: color/theme information +// Parts are created fresh and linked via relationships. + +// Example: Add styles part (covered in Section 2) +var stylesPart = mainPart.AddNewPart(); +stylesPart.Styles = new Styles(); +stylesPart.Styles.Save(); + +// Example: Add settings part (covered in 1.4) +var settingsPart = mainPart.AddNewPart(); +settingsPart.Settings = new Settings(); +settingsPart.Settings.Save(); + +// --- STEP 4: ADD CONTENT TO BODY --- +// Body accepts: Paragraph (w:p), Table (w:tbl), Structured Document Tag (w:sdt) +// Content is added in document order (no need for explicit index). +// IMPORTANT: SectionProperties (w:sectPr) MUST be the last child of body. +body.Append(new Paragraph( + new Run(new Text("Hello, World!")))); + +// --- STEP 5: SET SECTION PROPERTIES (PAGE LAYOUT) --- +// sectPr defines page size, margins, headers/footers, columns, etc. +// It must be the last child of body. If missing, Word uses defaults (Letter/A4, 1" margins). +var sectPr = new SectionProperties(); + +// Page Size: Width/Height in DXA (1 inch = 1440 DXA) +// Letter: 12240 x 15840 DXA (8.5" x 11") +// A4: 11906 x 16838 DXA (210mm x 297mm) +sectPr.Append(new PageSize +{ + Width = 12240u, // 8.5 inches + Height = 15840u // 11 inches +}); + +// Page Margins: all four margins in DXA +// Note: Top+Bottom margins + HeaderDistance = distance from page edge to text +sectPr.Append(new PageMargin +{ + Top = 1440, // 1 inch + Bottom = 1440, // 1 inch + Left = 1440u, // 1 inch (uint required) + Right = 1440u, // 1 inch + Header = 720u, // 0.5 inch from page edge to header + Footer = 720u // 0.5 inch from page edge to footer +}); + +// Attach sectPr to body (must be last) +body.Append(sectPr); + +// --- STEP 6: SAVE --- +// Because we use `using`, Dispose() is called automatically when the block exits. +// Dispose() saves the file. If you forget `using`, call doc.Save() explicitly. +``` + +### 1.2 Opening an Existing Document + +```csharp +// ============================================================================= +// OPENING EXISTING DOCUMENTS +// ============================================================================= +// Open() has multiple overloads: +// 1. Open(string path, bool isEditable, AutoSave) +// 2. Open(Stream, bool isEditable, AutoSave) +// 3. Open(string path, bool isEditable, OpenSettings) +// +// isEditable=true means open for read/write. false = read-only. +// isEditable=false is faster (shared locks avoided) but throws if file is read-only. + +// --- OPEN FOR EDITING (READ/WRITE) --- +string inputPath = "C:\\Docs\\Existing.docx"; +using var editDoc = WordprocessingDocument.Open( + inputPath, + isEditable: true, // Required for modification + new OpenSettings + { + AutoSave = true // Automatically save on Dispose + }); + +var body = editDoc.MainDocumentPart!.Document.Body!; +// ... make changes ... +// No explicit Save() needed if AutoSave = true + +// --- OPEN AS READ-ONLY (FASTER) --- +using var readOnlyDoc = WordprocessingDocument.Open( + inputPath, + isEditable: false, // Read-only mode + new OpenSettings + { + // MarkupDeclarationProcess options + }); + +// --- OPEN FROM STREAM --- +byte[] fileBytes = File.ReadAllBytes(inputPath); +using var streamDoc = WordprocessingDocument.Open( + new MemoryStream(fileBytes), + isEditable: true, + new OpenSettings { AutoSave = false }); + +// After editing, you MUST copy the stream back to file if AutoSave=false: +// streamDoc.MainDocumentPart.Document.Save(); +// File.WriteAllBytes(outputPath, streamStream.ToArray()); + +// --- OPEN FROM HTTP RESPONSE (WEB SCENARIO) --- +using var httpClient = new HttpClient(); +var response = await httpClient.GetAsync("https://example.com/document.docx"); +using var webStream = await response.Content.ReadAsStreamAsync(); +using var webDoc = WordprocessingDocument.Open(webStream, isEditable: true); +``` + +### 1.3 Stream-Based Creation (MemoryStream for Web) + +```csharp +// ============================================================================= +// STREAM-BASED DOCUMENT CREATION +// ============================================================================= +// Use MemoryStream when you want to: +// 1. Generate a document in memory before sending to a client +// 2. Avoid touching the filesystem (ASP.NET Core scenarios) +// 3. Return a document from an API endpoint +// +// CRITICAL: The stream MUST be seekable when you call .Open(). +// After WordprocessingDocument.Create(), the stream position is at the beginning. +// If you write to the stream BEFORE creating the document, seek to 0 first. + +// --- CREATE IN MEMORY --- +MemoryStream memStream = new MemoryStream(); + +// Create directly on a stream (no file path involved) +using (var doc = WordprocessingDocument.Create( + memStream, + WordprocessingDocumentType.Document, + new DocumentOptions { AutoSave = false })) +{ + var mainPart = doc.MainDocumentPart!; + mainPart.Document = new Document(new Body()); + mainPart.Document.Body!.Append(new Paragraph( + new Run(new Text("Generated in memory")))); + mainPart.Document.Save(); // Save to the underlying stream +} +// At this point, memStream contains the complete DOCX + +// --- SEND TO HTTP RESPONSE (ASP.NET Core) --- +// In an API controller: +[HttpGet("download")] +public async Task DownloadDocument() +{ + var memStream = new MemoryStream(); + + using (var doc = WordprocessingDocument.Create( + memStream, + WordprocessingDocumentType.Document)) + { + var mainPart = doc.MainDocumentPart!; + mainPart.Document = new Document(new Body()); + mainPart.Document.Body!.Append(new Paragraph( + new Run(new Text("Download me!")))); + mainPart.Document.Save(); + } + + memStream.Position = 0; // IMPORTANT: Reset position for reading + return File(memStream, + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "GeneratedDocument.docx"); +} + +// --- CREATE FROM TEMPLATE IN MEMORY --- +// Useful for mail-merge style operations +MemoryStream templateStream = new MemoryStream(); +File.WriteAllBytes("template.docx", templateStream.ToArray()); // Save a template first + +using var templateSource = new MemoryStream(File.ReadAllBytes("template.docx")); +using var mergedDoc = (WordprocessingDocument)templateSource.Clone(); + +// Clone() creates an editable copy. Don't forget to set position: +mergedDoc.MainDocumentPart!.Document.Body!.Append(new Paragraph( + new Run(new Text("Added content")))); +``` + +### 1.4 Adding All Standard Parts + +```csharp +// ============================================================================= +// ADDING ALL STANDARD DOCUMENT PARTS +// ============================================================================= +// A complete document should have: +// 1. MainDocumentPart (auto-created) +// 2. StyleDefinitionsPart +// 3. NumberingDefinitionsPart +// 4. DocumentSettingsPart +// 5. ThemePart (optional) +// 6. Custom parts (headers, footers, comments, etc.) + +// --- COMPLETE SETUP METHOD --- +public static void CreateCompleteDocument(string path) +{ + using var doc = WordprocessingDocument.Create(path, WordprocessingDocumentType.Document); + var mainPart = doc.MainDocumentPart!; + + // Initialize document + mainPart.Document = new Document(new Body()); + var body = mainPart.Document.Body!; + + // Add all parts + AddStylesPart(mainPart); + AddNumberingPart(mainPart); + AddSettingsPart(mainPart); + AddThemePart(mainPart); + AddHeadersAndFooters(mainPart); + + // Add sample content + AddSampleContent(body); + + // Section properties MUST be last + body.Append(CreateSectionProperties()); + + mainPart.Document.Save(); +} + +// --- STYLES PART --- +// See Section 2 for detailed style creation +private static void AddStylesPart(MainDocumentPart mainPart) +{ + var stylesPart = mainPart.AddNewPart(); + var styles = new Styles(); + + // DocDefaults: document-wide defaults for run and paragraph properties + // These apply when no explicit style or direct formatting overrides them + styles.Append(new DocDefaults( + new RunPropertiesDefault( + new RunPropertiesBaseStyle( + new RunFonts { Ascii = "Calibri", HighAnsi = "Calibri" }, + new FontSize { Val = "22" }, // 22 half-points = 11pt + new FontSizeComplexScript { Val = "22" } + ) + ), + new ParagraphPropertiesDefault( + new ParagraphPropertiesBaseStyle( + new SpacingBetweenLines { After = "200", Line = "276", LineRule = LineSpacingRuleValues.Auto } + ) + ) + )); + + // Default Normal style + styles.Append(new Style( + new StyleName { Val = "Normal" }, + new PrimaryStyle() + ) + { Type = StyleValues.Paragraph, StyleId = "Normal", Default = true }); + + stylesPart.Styles = styles; + stylesPart.Styles.Save(); +} + +// --- NUMBERING PART --- +// Required for bulleted and numbered lists +private static void AddNumberingPart(MainDocumentPart mainPart) +{ + var numberingPart = mainPart.AddNewPart(); + var numbering = new Numbering(); + + // AbstractNum defines the list format (bullet, number, multilevel) +// Creates a bullet list definition with 3 levels + var abstractNum = new AbstractNum { AbstractNumberId = 1 }; + + // Level 0: Bullet (dot) + abstractNum.Append(new Level( + new StartNumberingValue { Val = 1 }, + new NumberingFormat { Val = NumberFormatValues.Bullet }, + new LevelText { Val = "•" }, + new LevelJustification { Val = LevelJustificationValues.Left }, + new PreviousParagraphProperties( + new Indentation { Left = "720", Hanging = "360" }) // 720 DXA indent, 360 DXA hanging + ) + { LevelIndex = 0 }); + + // Level 1: Dash + abstractNum.Append(new Level( + new StartNumberingValue { Val = 1 }, + new NumberingFormat { Val = NumberFormatValues.Bullet }, + new LevelText { Val = "–" }, + new LevelJustification { Val = LevelJustificationValues.Left }, + new PreviousParagraphProperties( + new Indentation { Left = "1440", Hanging = "360" }) + ) + { LevelIndex = 1 }); + + // Level 2: Circle + abstractNum.Append(new Level( + new StartNumberingValue { Val = 1 }, + new NumberingFormat { Val = NumberFormatValues.Bullet }, + new LevelText { Val = "◦" }, + new LevelJustification { Val = LevelJustificationValues.Left }, + new PreviousParagraphProperties( + new Indentation { Left = "2160", Hanging = "360" }) + ) + { LevelIndex = 2 }); + + numbering.Append(abstractNum); + + // NumberingInstance links to AbstractNum and assigns a numId + numbering.Append(new NumberingInstance( + new AbstractNumId { Val = 1 } + ) + { NumberID = 1 }); + + numberingPart.Numbering = numbering; + numberingPart.Numbering.Save(); +} + +// --- SETTINGS PART --- +// Contains document-level settings: zoom, proof state, default tab stop, etc. +private static void AddSettingsPart(MainDocumentPart mainPart) +{ + var settingsPart = mainPart.AddNewPart(); + var settings = new Settings(); + + // Zoom: document zoom percentage (default 100%) + // Val is a percentage value (e.g., "100" = 100%) + settings.Append(new Zoom { Val = "100", Percent = true, SnapToGrid = true }); + + // ProofState: spelling/grammar check state + // Val combines bits: 1=grammar, 2=spelling, 3=both + settings.Append(new ProofState { Val = ProofingStateValues.Clean }); + + // Default tab stop interval in DXA + // Word inserts tab stops every 720 DXA (0.5 inch) by default + settings.Append(new DefaultTabStop { Val = 720 }); + + // Character spacing control: automatically adjust character spacing + // to maintain consistent line spacing (similar to InDesign) + settings.Append(new CharacterSpacingControl { Val = CharacterSpacingValues.CompressPunctuation }); + + // Compatibility settings: controls how Word handles certain formatting + // to ensure compatibility with different Word versions + settings.Append(new Compatibility( + new UseFELayout(), // Use formatted East Asian layout + new UseAsianDigraphicLineBreakRules(), // CJK line breaking rules + new AllowSpaceOfSameStyleInTable(), // Table cell spacing + new DoNotUseIndentAsPercentageForTabStops(), // Legacy tab behavior + new ProportionalOtherIndents(), // Proportional indents + new LayoutTableRawTextInTable() // Raw text in layout tables + )); + + // Revision tracking view settings + settings.Append(new RevisionView { DocPart = false, Formatting = true, Ink = true, Markup = true }); + + settingsPart.Settings = settings; + settingsPart.Settings.Save(); +} + +// --- THEME PART --- +// Defines color scheme, font scheme, and format scheme for the document theme +private static void AddThemePart(MainDocumentPart mainPart) +{ + var themePart = mainPart.AddNewPart(); + var theme = new Theme( + new ThemeElements( + // Color scheme: 10 predefined theme colors + new ColorScheme( + new Dark1Color(new Color { Val = "000000" }), + new Light1Color(new Color { Val = "FFFFFF" }), + new Dark2Color(new Color { Val = "1F497D" }), + new Light2Color(new Color { Val = "EEECE1" }), + new Accent1Color(new Color { Val = "4F81BD" }), + new Accent2Color(new Color { Val = "C0504D" }), + new Accent3Color(new Color { Val = "9BBB59" }), + new Accent4Color(new Color { Val = "8064A2" }), + new Accent5Color(new Color { Val = "4BACC6" }), + new Accent6Color(new Color { Val = "F79646" }), + new Hyperlink(new Color { Val = "0000FF" }), + new FollowedHyperlinkColor(new Color { Val = "800080" }) + ), + // Font scheme: major (headings) and minor (body) fonts + new FontScheme( + new MajorFont { Val = "Calibri Light" }, + new MinorFont { Val = "Calibri" } + ), + // Format scheme: default fill and effect styles + new FormatScheme( + new FillStyleList( + new FillStyle { Fill = new PatternFill { PatternType = PatternValues.Solid } } + ), + new LineStyleList( + new LineStyle { Val = LineValues.Single } + ) + ) + ), + new ThemeName { Val = "Office Theme" }, + new ThemeNames( + new LanguageBasedString { Val = "en-US", LanguageId = "x-none" } + ) + ); + + themePart.Theme = theme; + themePart.Theme.Save(); +} + +// --- HEADERS AND FOOTERS --- +private static void AddHeadersAndFooters(MainDocumentPart mainPart) +{ + // Header + var headerPart = mainPart.AddNewPart(); + headerPart.Header = new Header( + new Paragraph( + new ParagraphProperties( + new Justification { Val = JustificationValues.Right }), + new Run( + new RunProperties( + new RunFonts { Ascii = "Calibri Light", HighAnsi = "Calibri Light" }, + new Italic(), + new FontSize { Val = "20" } // 10pt + ), + new Text("Document Header")) + )); + var headerId = mainPart.GetIdOfPart(headerPart); + + // Footer + var footerPart = mainPart.AddNewPart(); + footerPart.Footer = new Footer( + new Paragraph( + new ParagraphProperties( + new Justification { Val = JustificationValues.Center }), + new Run(new Text("Page ") { Space = SpaceProcessingModeValues.Preserve }), + new Run(new FieldChar { FieldCharType = FieldCharValues.Begin }), + new Run(new FieldCode(" PAGE ") { Space = SpaceProcessingModeValues.Preserve }), + new Run(new FieldChar { FieldCharType = FieldCharValues.End }), + new Run(new Text(" of ") { Space = SpaceProcessingModeValues.Preserve }), + new Run(new FieldChar { FieldCharType = FieldCharValues.Begin }), + new Run(new FieldCode(" NUMPAGES ") { Space = SpaceProcessingModeValues.Preserve }), + new Run(new FieldChar { FieldCharType = FieldCharValues.End }) + )); + var footerId = mainPart.GetIdOfPart(footerPart); + + // Reference IDs in section properties + // (added in CreateSectionProperties below) +} + +// --- SECTION PROPERTIES (COMPLETE) --- +private static SectionProperties CreateSectionProperties() +{ + var sectPr = new SectionProperties(); + + // Header/Footer references (must come before page size/margins) + var mainPart = doc.MainDocumentPart; // Note: in real code, pass as parameter + sectPr.Append(new HeaderReference + { + Type = HeaderFooterValues.Default, + Id = mainPart!.GetIdOfPart(mainPart.HeaderParts.First()) + }); + sectPr.Append(new FooterReference + { + Type = HeaderFooterValues.Default, + Id = mainPart.GetIdOfPart(mainPart.FooterParts.First()) + }); + + // Page size + sectPr.Append(new PageSize { Width = 12240u, Height = 15840u }); + + // Page margins + sectPr.Append(new PageMargin + { + Top = 1440, + Bottom = 1440, + Left = 1440u, + Right = 1440u, + Header = 720u, + Footer = 720u + }); + + // Page numbering format + sectPr.Append(new PageNumberType { Start = 1, Format = NumberFormatValues.Decimal }); + + // Column settings (default: 1 column) + sectPr.Append(new Columns { ColumnCount = 1, EqualWidth = true }); + + // Paper source (printer tray) + // sectPr.Append(new PaperSource { Tray = 1, Paper = 7 }); + + return sectPr; +} +``` + +### 1.5 Unit Systems Reference + +```csharp +// ============================================================================= +// UNIT SYSTEMS IN OPENXML +// ============================================================================= +// Understanding units is critical. Wrong unit = wrong formatting. +// +// DXA (Twentieths of a DXA) - "Standard Document Unit" +// 1 DXA = 1/20th of a point +// 1 inch = 1440 DXA +// 1 cm = 567 DXA (approx) +// Used for: margins, indents, spacing, tab stops, column widths +// +// Half-Points (sz) - Font Size +// Value is in half-points (1/2 point increments) +// 24 = 12pt, 28 = 14pt, 36 = 18pt, 48 = 24pt +// Used for: FontSize.Val, FontSizeComplexScript.Val +// +// Points (pt) - Direct Measurements +// Standard typographic point (72 per inch) +// Used for: some line spacing values, border widths +// +// EMU (English Metric Units) - Drawing Objects +// 1 inch = 914400 EMU +// Used for: drawing object sizes, shapes, images +// +// STARS (Special Twips Advanced Right-Left) - CJK Indentation +// Used for: FirstLineChars, HangingChars (special FirstLine/Hanging for CJK) +// Converts character counts to DXA based on font metrics +// +// LINE SPACING SPECIAL VALUES: +// Line = "240" with LineRule = Auto = single spacing (default) +// Line = "480" with LineRule = Auto = double spacing +// Line = "360" with LineRule = Auto = 1.5 spacing +// Line = "240" with LineRule = Exact = exactly 12pt +// Line = "288" with LineRule = AtLeast = at least 14.4pt (grows with content) + +// --- CONVERSION HELPER METHODS --- +public static class OpenXmlUnits +{ + // DXA conversions + public static int InchesToDxa(double inches) => (int)(inches * 1440); + public static int CmToDxa(double cm) => (int)(cm * 567.0); + public static int PtToDxa(double pt) => (int)(pt * 20); + public static double DxaToInches(int dxa) => dxa / 1440.0; + public static double DxaToCm(int dxa) => dxa / 567.0; + public static double DxaToPt(int dxa) => dxa / 20.0; + + // EMU conversions (for drawings) + public static long InchesToEmu(double inches) => (long)(inches * 914400); + public static long CmToEmu(double cm) => (long)(cm * 360000); + public static double EmuToInches(long emu) => emu / 914400.0; + + // Half-point conversions (font sizes) + public static int PtToHalfPt(double pt) => (int)(pt * 2); + public static int FontSizeToSz(double ptSize) => (int)(ptSize * 2); + public static double SzToPt(int sz) => sz / 2.0; + + // Line spacing + public static int SingleSpacing => 240; + public static int DoubleSpacing => 480; + public static int OneAndHalfSpacing => 360; + public static int LineSpacingPt(double pt) => (int)(pt * 20); // Convert to DXA +} + +// Example usage: +var marginInInches = OpenXmlUnits.DxaToInches(1440); // 1.0 +var fontSizeInSz = OpenXmlUnits.FontSizeToSz(12.0); // 24 +var indentInDxa = OpenXmlUnits.InchesToDxa(0.5); // 720 +``` + +--- + +## 2. Style System Deep Dive + +### 2.1 Style Types and Structure + +```csharp +// ============================================================================= +// STYLE TYPES OVERVIEW +// ============================================================================= +// OpenXML defines 4 style types (StyleValues enum): +// 1. Paragraph (w:p) - controls paragraph-level formatting +// 2. Character (w:r) - controls inline/run-level formatting +// 3. Table (w:tbl) - controls table-level formatting +// 4. Numbering (w:num) - NOT a style type, but a separate numbering system +// +// Key insight: A style can be BOTH paragraph and character style (linked style). +// The "linkedStyle" element links a paragraph style to a character style. + +// --- MINIMAL PARAGRAPH STYLE --- +// A paragraph style controls: pPr (paragraph properties) and optionally rPr +Style minimalParaStyle = new Style( + new StyleName { Val = "MyParagraphStyle" }, + new PrimaryStyle() // Primary styles appear in Style gallery +) +{ + Type = StyleValues.Paragraph, + StyleId = "MyParagraphStyle" +}; + +// --- MINIMAL CHARACTER STYLE --- +// A character style controls: rPr only (no pPr) +Style minimalCharStyle = new Style( + new StyleName { Val = "MyCharacterStyle" }, + new PrimaryStyle() +) +{ + Type = StyleValues.Character, + StyleId = "MyCharacterStyle" +}; + +// Character style with run properties (fonts, size, bold, etc.) +Style charStyleWithFormatting = new Style( + new StyleName { Val = "Emphasis" }, + new PrimaryStyle(), + new StyleRunProperties( + new Italic(), + new Color { Val = "C00000" } // Dark red + ) +) +{ + Type = StyleValues.Character, + StyleId = "Emphasis" +}; + +// --- LINKED STYLE (Paragraph + Character) --- +// A linked style combines both: it can be applied to a paragraph OR a run. +// This is how Word's "Heading 1" works — applies to paragraphs, but you can +// also select text within a heading and apply the same style as character formatting. +Style linkedStyle = new Style( + new StyleName { Val = "LinkedStyle" }, + new PrimaryStyle(), + new LinkedStyle { Val = "LinkedStyleChar" }, // Links to character style + new StyleParagraphProperties( + new SpacingBetweenLines { After = "120" } + ), + new StyleRunProperties( + new Bold(), + new FontSize { Val = "24" } + ) +) +{ + Type = StyleValues.Paragraph, + StyleId = "LinkedStyle" +}; + +// Corresponding character style (normally same name + "Char" suffix by convention) +Style linkedStyleChar = new Style( + new StyleName { Val = "LinkedStyle Char" }, // Word convention: adds " Char" + new PrimaryStyle(), + new StyleRunProperties( + new Bold(), + new FontSize { Val = "24" } + ) +) +{ + Type = StyleValues.Character, + StyleId = "LinkedStyleChar" +}; + +// --- TABLE STYLE --- +Style tableStyle = new Style( + new StyleName { Val = "MyTableStyle" }, + new PrimaryStyle(), + new StyleTableProperties( + new TableWidth { Width = "5000", Type = TableWidthUnitValues.Pct }, // 50% width + new TableBorders( + new TopBorder { Val = BorderValues.Single, Size = 4, Color = "000000" }, + new BottomBorder { Val = BorderValues.Single, Size = 4, Color = "000000" }, + new LeftBorder { Val = BorderValues.Single, Size = 4, Color = "000000" }, + new RightBorder { Val = BorderValues.Single, Size = 4, Color = "000000" }, + new InsideHorizontalBorder { Val = BorderValues.Single, Size = 2, Color = "CCCCCC" }, + new InsideVerticalBorder { Val = BorderValues.Single, Size = 2, Color = "CCCCCC" } + ), + new TableCellMarginDefault( + new TopMargin { Width = "0", Type = TableWidthUnitValues.DXA }, + new StartMargin { Width = "108", Type = TableWidthUnitValues.DXA }, + new BottomMargin { Width = "0", Type = TableWidthUnitValues.DXA }, + new EndMargin { Width = "108", Type = TableWidthUnitValues.DXA } + ) + ) +) +{ + Type = StyleValues.Table, + StyleId = "MyTableStyle" +}; +``` + +### 2.2 DocDefaults and Document-Wide Defaults + +```csharp +// ============================================================================= +// DOCDEFAULTS: DOCUMENT-WIDE DEFAULTS +// ============================================================================= +// DocDefaults lives inside Styles and provides fallback values when: +// 1. No explicit style is applied +// 2. No direct formatting is applied +// It contains RunPropertiesDefault and/or ParagraphPropertiesDefault. +// +// CRITICAL: DocDefaults applies to the entire document. Any explicit style +// or direct formatting will override it. + +// --- COMPLETE DOCDEFAULTS SETUP --- +var docDefaults = new DocDefaults( + // Run properties defaults: default font, size, language for all runs + new RunPropertiesDefault( + new RunPropertiesBaseStyle( + // RunFonts: which font to use for each script + // Word will fall back through these: ASCII -> HighAnsi -> EastAsia -> ComplexScript + // Always specify at minimum Ascii and HighAnsi + new RunFonts + { + Ascii = "Calibri", // Western/Latin font (primary) + HighAnsi = "Calibri", // Latin characters (often same as Ascii) + EastAsia = "SimSun", // East Asian font (CJK) + ComplexScript = "Arial", // Complex scripts (Arabic, Hebrew, Thai) + ASCIITheme = ThemeFontValues.Minor, + HighAnsiTheme = ThemeFontValues.Minor, + EastAsiaTheme = ThemeFontValues.Minor, + ComplexScriptTheme = ThemeFontValues.Minor + }, + // FontSize: in HALF-POINTS (24 = 12pt, 22 = 11pt, 20 = 10pt) + new FontSize { Val = "22" }, // 11pt for body + new FontSizeComplexScript { Val = "22" }, + // Languages: required for proper hyphenation and spell checking + new Languages { Val = "en-US" }, // Default language + new Languages { EastAsia = "zh-CN", Val = "en-US" } // Can set multiple + ) + ), + // Paragraph properties defaults: default spacing, etc. + new ParagraphPropertiesDefault( + new ParagraphPropertiesBaseStyle( + // SpacingBetweenLines: default paragraph spacing + // After = "200" = 200 DXA = 10pt after each paragraph + new SpacingBetweenLines + { + After = "200", + Line = "276", + LineRule = LineSpacingRuleValues.Auto // Auto = 1.15x line height + } + ) + ) +); + +// --- LAYOUT LUNCTIONS (LATENT STYLES) --- +// Latent styles are hidden styles that exist in Word but aren't in styles.xml. +// They provide fast-access defaults for formatting (e.g., Normal, Heading 1-6, etc.) +// when the user hasn't explicitly customized them. +// +// DocDefaults can define LatentStyleCountOverride to adjust count, +// but true latent styles are controlled by Normal.dotm (Word's global template). +Styles CreateStylesWithDocDefaults() +{ + var styles = new Styles(); + + // DocDefaults with run and paragraph properties defaults + styles.Append(new DocDefaults( + new RunPropertiesDefault( + new RunPropertiesBaseStyle( + new RunFonts { Ascii = "Calibri", HighAnsi = "Calibri" }, + new FontSize { Val = "22" }, + new Languages { Val = "en-US" } + ) + ), + new ParagraphPropertiesDefault( + new ParagraphPropertiesBaseStyle( + new SpacingBetweenLines { After = "160", Line = "276", LineRule = LineSpacingRuleValues.Auto } + ) + ) + )); + + // LatentStyles: override defaults for built-in latent styles + // These control Word's "fast-styles" like Heading 1-6 before they're customized + styles.Append(new LatentStyles( + new Count { Val = 159 }, // Total latent style count + new FirstLineChars { Val = 352 }, // Default first line char count + new HorizontalOverflow { Val = HorizontalOverflowValues.Overflow }, + new VerticalOverflow { Val = VerticalOverflowValues.Overflow }, + new KoreanSpaceAdjust { Val = true }, + // Each LatentStyleException overrides ONE attribute of ONE latent style + // StyleID = the built-in style name (e.g., "Normal", "heading 1") + // Attribute: what to change (bold, italic, font, color, etc.) + // The defaults for built-in headings: font=Calibri, size=24, bold + new LatentStyleException( + new Primary烙, + new StyleName { Val = "Normal" }, + new UIPriority { Val = 1 }, + new PrimaryZone(), + new QuickStyle() + ), + new LatentStyleException( + new Primary烙, + new StyleName { Val = "heading 1" }, + new UIPriority { Val = 9 }, + new PrimaryZone(), + new QuickStyle(), + new Bold(), + new BoldComplexScript(), + new FontSize { Val = "48" }, // 24pt = 48 half-pts + new FontSizeComplexScript { Val = "48" } + ) + )); + + return styles; +} +``` + +### 2.3 Complete Heading Styles Hierarchy + +```csharp +// ============================================================================= +// HEADING STYLES WITH PROPER INHERITANCE CHAIN +// ============================================================================= +// Word's built-in heading system uses style inheritance: +// Normal (base) -> Heading1 -> Heading2 -> Heading3 -> Heading4 -> Heading5 -> Heading6 +// +// Why this matters: +// - Each heading INHERITS from its parent (basedOn) +// - Define common properties in Normal, override in each heading +// - Change body font once in Normal, all headings inherit it +// - Heading-specific properties override as needed + +// --- HEADING STYLE FACTORY --- +public static Style CreateHeadingStyle(int level, FontConfig fonts) +{ + // Validate level (1-9 are valid, 1-6 are standard) + if (level < 1 || level > 9) + throw new ArgumentOutOfRangeException(nameof(level)); + + double[] headingSizes = [26.0, 20.0, 16.0, 14.0, 12.0, 11.0, 11.0, 11.0, 11.0]; + string[] outlineLevels = ["0", "1", "2", "3", "4", "5", "6", "7", "8"}; + + var style = new Style( + new StyleName { Val = $"heading {level}" }, // Display name + new BasedOn { Val = level == 1 ? "Normal" : $"Heading{level - 1}" }, // Parent style + new NextParagraphStyle { Val = "Normal" }, // After heading -> Normal + new PrimaryStyle(), // Show in Styles gallery + new UIPriority { Val = 9 - level }, // Priority in gallery (H1 = 8, H2 = 7, etc.) + new QuickStyle(), // Appears in Quick Styles gallery + // Paragraph properties: spacing, keep options, outline level + new StyleParagraphProperties( + new KeepNext(), // Keep heading with next paragraph + new KeepLines(), // Keep all lines of heading together + new SpacingBetweenLines // Spacing before/after + { + Before = level == 1 ? "480" : "240", // H1 = 240pt before, others = 120pt + After = "120" + }, + new OutlineLevel { Val = level - 1 } // 0-indexed for H1=0, H2=1, etc. + ), + // Run properties: font, size, bold + new StyleRunProperties( + new RunFonts + { + Ascii = fonts.HeadingFont, + HighAnsi = fonts.HeadingFont, + EastAsia = "SimHei" // Bold heading font for CJK + }, + new FontSize { Val = UnitConverter.FontSizeToSz(headingSizes[level - 1]) }, + new FontSizeComplexScript { Val = UnitConverter.FontSizeToSz(headingSizes[level - 1]) }, + new Bold(), + new BoldComplexScript() + ) + ) + { + Type = StyleValues.Paragraph, + StyleId = $"Heading{level}" + }; + + return style; +} + +// --- ADD ALL HEADING STYLES TO STYLES COLLECTION --- +public static void AddHeadingStyles(Styles styles, FontConfig fonts) +{ + for (int i = 1; i <= 6; i++) + { + styles.Append(CreateHeadingStyle(i, fonts)); + } + + // Also add Heading 7-9 (valid in Word, less commonly used) + for (int i = 7; i <= 9; i++) + { + styles.Append(CreateHeadingStyle(i, fonts)); + } +} + +// --- HEADING STYLES INHERITANCE VISUALIZATION --- +// When you apply "Heading2" (basedOn="Heading1"): +// +// Normal style: +// - Font: Calibri 11pt +// - Spacing: 0 before, 200 after +// - No bold +// +// Heading1 (basedOn="Normal"): +// - Inherits: Calibri 11pt +// - Overrides: Calibri Light 26pt, Bold, Spacing 480 before/120 after +// - Adds: KeepNext, KeepLines, OutlineLevel=0 +// +// Heading2 (basedOn="Heading1"): +// - Inherits: Calibri Light 26pt, Bold, KeepNext, KeepLines +// - Overrides: 20pt +// - Inherits: OutlineLevel=1 +// +// Effective result: Heading2 = Calibri Light 20pt Bold, KeepNext+KeepLines, 480/120 spacing, OL=1 +``` + +### 2.4 Style Inheritance Chain Resolution + +```csharp +// ============================================================================= +// STYLE INHERITANCE RESOLUTION +// ============================================================================= +// OpenXML styles resolve properties through the basedOn chain at RENDER TIME. +// The document.xml stores only the styleId, not the resolved properties. +// Word (or this library) walks the chain at load/display time. +// +// Example: Applying "Heading2" to a paragraph +// +// 1. Start with Heading2 style definition +// 2. Walk basedOn chain: Heading2 -> Heading1 -> Normal -> (null) +// 3. Collect properties in reverse order (most generic first): +// a. Normal: Ascii=Calibri, sz=22, no bold +// b. Heading1: Ascii=Calibri Light, sz=48, bold (override Calibri, sz, bold) +// c. Heading2: sz=40 (override sz only) +// 4. Final resolved style: Ascii=Calibri Light, sz=40, bold (bold from H1) +// +// IMPORTANT: Style override is COMPLETE for each element type: +// - If Normal has rPr with Fonts, and Heading1 has pPr only, +// Heading1 still inherits Normal's rPr fully. +// - StyleRunProperties (rPr) and StyleParagraphProperties (pPr) are separate. + +// --- RESOLVING STYLE PROPERTIES MANUALLY --- +// For debugging or custom rendering, you may need to resolve style chains +public static class StyleResolver +{ + public record ResolvedStyle( + StyleName? Name, + RunProperties? RunProps, + ParagraphProperties? ParaProps, + string? BasedOn, + string Type); + + public static ResolvedStyle Resolve(Styles styles, string styleId) + { + var styleMap = styles.Elements + + +
+ +
+ +
+ + {dot_grid} + +
+
{t.get('doc_type','Document').upper()}  ·  {t.get('date','')}
+
{t['title']}
+
+ {subtitle_block} +
+ + +
+""" + + +# ── Pattern 2: Split panel ───────────────────────────────────────────────────── +def _pattern_split(t: dict) -> str: + dot_grid = _dot_grid( + x0=360, y0=120, cols=10, rows=18, gap=22, r=2, + color="#CCCCCC", opacity=0.25 + ) + return f""" + + + + + +
+
+
+
{t['title']}
+
+ {'
' + t['subtitle'] + '
' if t.get('subtitle') else ''} +
{t.get('author','')}
+
{t.get('date','')}
+
+
+ {dot_grid} +
+
+
{t.get('doc_type','').upper()}
+
+""" + + +# ── Pattern 3: Typographic ───────────────────────────────────────────────────── +def _pattern_typographic(t: dict) -> str: + words = t['title'].split() + first = words[0] if words else "" + rest = " ".join(words[1:]) if len(words) > 1 else "" + return f""" + + + + + +
+
+
{first}
+ {'
' + rest + '
' if rest else ''} +
+
+
{t.get('author','')}
+
{t.get('date','')}
+
+ {'
' + t['subtitle'] + '
' if t.get('subtitle') else ''} +
+
+""" + + +# ── Pattern 4: Dark atmospheric ──────────────────────────────────────────────── +def _pattern_atmospheric(t: dict) -> str: + dot_grid = _dot_grid( + x0=60, y0=60, cols=16, rows=22, gap=20, r=1.5, + color=t["accent"], opacity=0.08 + ) + return f""" + + + + + +
+
+
+ {dot_grid} +
+
+
{t.get('doc_type','').upper()}  ·  {t.get('date','')}
+
{t['title']}
+
+ {'
' + t['subtitle'] + '
' if t.get('subtitle') else ''} +
+ +
+""" + + +# ── Pattern 5: Minimal — thick left bar, generous whitespace ─────────────────── +def _pattern_minimal(t: dict) -> str: + """ + Ultra-restrained: white background, 8px left accent bar, oversized light-weight + title, nothing else but a hairline rule and minimal metadata. The bar is the only + color on the page — everything else is black on white. + """ + # Pick text color for page (minimal uses page_bg which is near-white) + text_dark = t.get("dark", "#111111") + muted = t.get("muted", "#999999") + accent = t["accent"] + + subtitle_block = "" + if t.get("subtitle"): + subtitle_block = f'
{t["subtitle"]}
' + + return f""" + + + + + +
+
+
+
{t.get('doc_type','').upper()}
+
{t['title']}
+
+ {subtitle_block} +
{t.get('author','')}{(' · ' + t.get('date','')) if t.get('date') else ''}
+
+
+""" + + +# ── Pattern 6: Stripe — bold horizontal bands ────────────────────────────────── +def _pattern_stripe(t: dict) -> str: + """ + Page divided into three bold horizontal bands: + - Top band (accent, ~18%): document type label + - Middle band (dark, ~52%): large title in white + - Bottom band (page bg, ~30%): author / date / subtitle + Hard geometry, no gradients, no textures. Newspaper / brand poster aesthetic. + """ + top_h = 200 # accent band + mid_h = 580 # dark band + bot_y = top_h + mid_h # 780 + + accent = t["accent"] + dark = t.get("cover_bg", "#1A1A2E") + light = t.get("page_bg", "#FAFAF8") + text_l = t.get("text_light", "#FFFFFF") + muted = t.get("muted", "#888888") + + subtitle_block = "" + if t.get("subtitle"): + subtitle_block = f'
{t["subtitle"]}
' + + return f""" + + + + + +
+
+
{t.get('doc_type','').upper()}
+
+
+
{t['title']}
+
+
+
+
{t.get('author','')}
+
{t.get('date','')}
+ {subtitle_block} +
+
+""" + + +# ── Pattern 7: Diagonal — angled color split ─────────────────────────────────── +def _pattern_diagonal(t: dict) -> str: + """ + SVG polygon cuts the page diagonally: upper-left in dark cover color, + lower-right in light page bg. Title sits on the dark area, metadata on light. + One angled edge — no gradients, no curves. + """ + dark_bg = t.get("cover_bg", "#1B2A4A") + light_bg = t.get("page_bg", "#FAFCFF") + accent = t["accent"] + text_l = t.get("text_light", "#F8FAFF") + text_d = t.get("dark", "#0F1A2E") + muted = t.get("muted", "#7A8A99") + + # Polygon: full upper-left to ~60% down on right side + # Points: top-left, top-right, (794, 620), (0, 820) + poly = "0,0 794,0 794,620 0,820" + + subtitle_block = "" + if t.get("subtitle"): + subtitle_block = f'
{t["subtitle"]}
' + + return f""" + + + + + +
+ + + + + + + +
+
{t.get('doc_type','').upper()}  ·  {t.get('date','')}
+
{t['title']}
+
+
+ +
+
{t.get('author','')}
+ {subtitle_block} +
+
+""" + + +# ── Pattern 8: Frame — elegant inset border ──────────────────────────────────── +def _pattern_frame(t: dict) -> str: + """ + Classic formal layout: outer thin border line inset ~28px from page edges, + inner accent strip at top and bottom inside the frame. + Title centered in the frame space, classical serif typography. + Used for: academic papers, formal reports, legal docs, annual reports. + """ + bg = t.get("cover_bg", "#FAF8F3") + accent = t["accent"] + dark = t.get("dark", "#2A1A0A") + muted = t.get("muted", "#9A8A78") + + pad = 28 # frame inset from page edge + inner_w = 794 - 2 * pad + inner_h = 1123 - 2 * pad + + subtitle_block = "" + if t.get("subtitle"): + subtitle_block = f'
{t["subtitle"]}
' + + return f""" + + + + + +
+
+
+
+
+
+
+
+ +
+
{t.get('doc_type','').upper()}
+
+
{t['title']}
+
+ {subtitle_block} +
{t.get('author','')}{(' · ' + t.get('date','')) if t.get('date') else ''}
+
+
+""" + + +# ── Pattern 9: Editorial — oversized ghost letter + bold type ────────────────── +def _pattern_editorial(t: dict) -> str: + """ + Magazine / editorial feel: + - Oversized first-letter of title as a ghost background element (8–12% opacity) + - Bold category label at top in accent + - Title in very large condensed weight, flush-left + - Thin full-width rule separating title from metadata + - Author / date bottom-left, page type bottom-right + Designed for editorial reports, annual reviews, magazine-format content. + """ + bg = t.get("cover_bg", "#FFFFFF") + accent = t["accent"] + dark = t.get("dark", "#0A0A0A") + muted = t.get("muted", "#777777") + text_l = t.get("text_light", "#FFFFFF") + + # Ghost letter — first character of title + ghost = t['title'][0].upper() if t['title'] else "A" + + subtitle_block = "" + if t.get("subtitle"): + subtitle_block = f'
{t["subtitle"]}
' + + # Determine if background is dark (use light text) or light (use dark text) + is_dark_bg = ( + bg.startswith("#0") or bg.startswith("#1") or bg.startswith("#2") + ) + title_color = text_l if is_dark_bg else dark # noqa: F841 + body_color = text_l if is_dark_bg else dark + + return f""" + + + + + +
+
{ghost}
+
+
{t.get('doc_type','').upper()}
+ +
+
{t['title']}
+ {subtitle_block} +
+ + + +
+""" + + +# ── Pattern 10: Magazine — elegant centered with optional hero image ──────────── +def _pattern_magazine(t: dict) -> str: + """ + Upscale centered layout: company name + accent rule at top, large serif title, + decorative rule, italic subtitle, optional hero image, abstract block, author. + Used for: annual reports, strategic documents, formal publications. + """ + bg = t.get("cover_bg", "#F2F0EC") + accent = t["accent"] + dark = t.get("dark", "#0D1A2B") + muted = t.get("muted", "#888888") + org = t.get("doc_type", "").upper() + img_url = t.get("cover_image", "") + + subtitle_block = "" + if t.get("subtitle"): + subtitle_block = f'
{t["subtitle"]}
' + + image_block = "" + if img_url: + image_block = f""" +
+ +
""" + + abstract_block = "" + if t.get("abstract"): + abstract_block = f""" +
+ Abstract: + {t['abstract']} +
""" + + return f""" + + + + + +
+
{org}
+
+
{t['title']}
+
+ {subtitle_block} + {image_block} + {abstract_block} + {'
' if (t.get('abstract') or img_url) else '
'} +
{t.get('author','')}
+ +
+""" + + +# ── Pattern 11: Darkroom — dark magazine variant ──────────────────────────────── +def _pattern_darkroom(t: dict) -> str: + """ + Dark-background centered layout. Same structure as magazine but inverted: + deep navy page, white/silver text, accent rules in lighter tone. + Used for: premium reports, tech annual reviews, dark-themed documents. + """ + bg = t.get("cover_bg", "#151C27") + accent = t["accent"] + text_l = t.get("text_light", "#F0EDE6") + muted = t.get("muted", "#8A9AB0") + org = t.get("doc_type", "").upper() + img_url = t.get("cover_image", "") + + subtitle_block = "" + if t.get("subtitle"): + subtitle_block = f'
{t["subtitle"]}
' + + image_block = "" + if img_url: + image_block = f""" +
+ +
""" + + abstract_block = "" + if t.get("abstract"): + abstract_block = f""" +
+ Abstract: + {t['abstract']} +
""" + + return f""" + + + + + +
+
{org}
+
+
{t['title']}
+
+ {subtitle_block} + {image_block} + {abstract_block} + {'
' if (t.get('abstract') or img_url) else '
'} +
{t.get('author','')}
+ +
+""" + + +# ── Pattern 12: Terminal — cyber/hacker aesthetic ─────────────────────────────── +def _pattern_terminal(t: dict) -> str: + """ + Dark terminal/IDE aesthetic: grid overlay, monospace font, neon accent, + corner brackets around the title block, status bar at bottom. + Used for: tech reports, developer docs, security audits, system documentation. + """ + bg = t.get("cover_bg", "#0D1117") + accent = t["accent"] + text_l = t.get("text_light", "#E6EDF3") + muted = t.get("muted", "#48897C") + dark = t.get("dark", "#010409") + org = t.get("doc_type", "DOCUMENT").upper() + date_s = t.get("date", "") + author = t.get("author", "") + + subtitle_line = "" + if t.get("subtitle"): + subtitle_line = f'
> {t["subtitle"]}
' + + abstract_block = "" + if t.get("abstract"): + abstract_block = f""" +
{t['abstract']}
""" + + # grid overlay: horizontal + vertical lines + h_lines = "".join( + f'' + for y in range(0, 1124, 48) + ) + v_lines = "".join( + f'' + for x in range(0, 795, 48) + ) + grid_svg = ( + f'' + + h_lines + v_lines + "" + ) + + return f""" + + + + + +
+ {grid_svg} + +
+
+
SYSTEM_REPORT // {date_s}
+
+ +
+
{t['title']}
+ {subtitle_line} +
+ +
+ {abstract_block} +
+
AUTHOR_ID
+
{author}
+
{org}
+
+
+ +
+
+
Ln 1, Col 1
+
UTF-8
+
GENERATED_BY_COVERGENIUS
+
+
+""" + + +# ── Pattern 13: Poster — bold sidebar + oversized type ───────────────────────── +def _pattern_poster(t: dict) -> str: + """ + Bold minimalist poster: thick vertical sidebar on the left, oversized all-caps + title, typewriter-style metadata. Optional thumbnail on the right side. + Used for: portfolios, creative reports, journalism, photography books. + """ + bg = t.get("cover_bg", "#FFFFFF") + accent = t["accent"] # typically black or strong dark + dark = t.get("dark", "#0A0A0A") + muted = t.get("muted", "#888888") + text_l = t.get("text_light", "#FFFFFF") + img_url = t.get("cover_image", "") + + sidebar_w = 52 + + subtitle_block = "" + if t.get("subtitle"): + subtitle_block = f'
{t["subtitle"]}
' + + image_block = "" + if img_url: + image_block = f""" + """ + + meta_lines = [] + if t.get("author"): + meta_lines.append(f'
{t["author"]}
') + if t.get("subtitle"): + meta_lines.append(f'
{t["subtitle"]}
') + if t.get("date"): + meta_lines.append(f'
{t["date"]}
') + meta_block = "\n".join(meta_lines) + + return f""" + + + + + +
+ + +
+
{t['title']}
+ {subtitle_block} +
+
{meta_block}
+
+ +
+ {image_block} +
+
+
+
+
+
+
+
+
+""" + + +# ── Dispatch ─────────────────────────────────────────────────────────────────── +PATTERNS = { + "fullbleed": _pattern_fullbleed, + "split": _pattern_split, + "typographic": _pattern_typographic, + "atmospheric": _pattern_atmospheric, + "minimal": _pattern_minimal, + "stripe": _pattern_stripe, + "diagonal": _pattern_diagonal, + "frame": _pattern_frame, + "editorial": _pattern_editorial, + "magazine": _pattern_magazine, + "darkroom": _pattern_darkroom, + "terminal": _pattern_terminal, + "poster": _pattern_poster, +} + + +def render(tokens: dict) -> str: + """Dispatch to the cover pattern function and return the HTML string.""" + pattern = tokens.get("cover_pattern", "fullbleed") + fn = PATTERNS.get(pattern, _pattern_fullbleed) + return fn(tokens) + + +# ── CLI ─────────────────────────────────────────────────────────────────────── +def main(): + """CLI entry point.""" + parser = argparse.ArgumentParser(description="Render cover HTML from tokens.json") + parser.add_argument("--tokens", default="tokens.json") + parser.add_argument("--out", default="cover.html") + parser.add_argument("--subtitle", default="", help="Optional subtitle override") + args = parser.parse_args() + + try: + with open(args.tokens, encoding="utf-8") as f: + tokens = json.load(f) + except FileNotFoundError: + print(json.dumps({"status": "error", "error": f"tokens file not found: {args.tokens}"}), + file=sys.stderr) + sys.exit(1) + except json.JSONDecodeError as e: + print(json.dumps({"status": "error", "error": f"invalid JSON: {e}"}), file=sys.stderr) + sys.exit(1) + + if args.subtitle: + tokens["subtitle"] = args.subtitle + + html = render(tokens) + + try: + with open(args.out, "w", encoding="utf-8") as f: + f.write(html) + except OSError as e: + print(json.dumps({"status": "error", "error": str(e)}), file=sys.stderr) + sys.exit(3) + + print(json.dumps({ + "status": "ok", + "out": args.out, + "pattern": tokens.get("cover_pattern"), + })) + + +if __name__ == "__main__": + main() diff --git a/backend/app/skills_builtin/minimax-pdf/scripts/fill_inspect.py b/backend/app/skills_builtin/minimax-pdf/scripts/fill_inspect.py new file mode 100644 index 0000000..3090715 --- /dev/null +++ b/backend/app/skills_builtin/minimax-pdf/scripts/fill_inspect.py @@ -0,0 +1,200 @@ +#!/usr/bin/env python3 +""" +fill_inspect.py — Inspect form fields in an existing PDF. + +Usage: + python3 fill_inspect.py --input form.pdf + python3 fill_inspect.py --input form.pdf --out fields.json + +Outputs a JSON summary of every fillable field: name, type, current value, +allowed values (for checkboxes / dropdowns), and page number. + +Exit codes: 0 success, 1 bad args / file not found, 2 dep missing, 3 read error +""" + +import argparse +import json +import sys +import importlib.util +import os + + + + +def ensure_deps(): + if importlib.util.find_spec("pypdf") is None: + import subprocess + subprocess.check_call( + [sys.executable, "-m", "pip", "install", "--break-system-packages", "-q", "pypdf"] + ) + + +ensure_deps() +from pypdf import PdfReader +from pypdf.generic import ArrayObject, DictionaryObject, NameObject, TextStringObject + + +# ── Field type resolution ────────────────────────────────────────────────────── +def _field_type(field) -> str: + ft = field.get("/FT") + if ft is None: + return "unknown" + ft = str(ft) + if ft == "/Tx": + return "text" + if ft == "/Btn": + ff = int(field.get("/Ff", 0)) + return "radio" if ff & (1 << 15) else "checkbox" + if ft == "/Ch": + ff = int(field.get("/Ff", 0)) + return "dropdown" if ff & (1 << 17) else "listbox" + if ft == "/Sig": + return "signature" + return "unknown" + + +def _field_value(field) -> str | None: + v = field.get("/V") + return str(v) if v is not None else None + + +def _field_options(field, ftype: str) -> dict: + extra = {} + if ftype in ("checkbox",): + ap = field.get("/AP") + if ap and "/N" in ap: + states = [str(k) for k in ap["/N"]] + extra["states"] = states + checked = next((s for s in states if s != "/Off"), None) + if checked: + extra["checked_value"] = checked + if ftype in ("dropdown", "listbox"): + opt = field.get("/Opt") + if opt: + choices = [] + for item in opt: + if isinstance(item, (list, ArrayObject)) and len(item) >= 2: + choices.append({"value": str(item[0]), "label": str(item[1])}) + else: + choices.append({"value": str(item), "label": str(item)}) + extra["choices"] = choices + if ftype == "radio": + kids = field.get("/Kids") + if kids: + values = [] + for kid in kids: + ap = kid.get("/AP") + if ap and "/N" in ap: + for k in ap["/N"]: + if str(k) != "/Off": + values.append(str(k)) + extra["radio_values"] = values + return extra + + +def _walk_fields(fields, page_map: dict, parent_name: str = "") -> list: + """Recursively collect all leaf fields.""" + result = [] + for field in fields: + name = str(field.get("/T", "")) + full = f"{parent_name}.{name}" if parent_name else name + + kids = field.get("/Kids") + # Kids that have /T are sub-fields (groups), not widget annotations + if kids: + named_kids = [k for k in kids if "/T" in k] + if named_kids: + result.extend(_walk_fields(named_kids, page_map, full)) + continue + + ftype = _field_type(field) + if ftype == "unknown": + continue + + entry = { + "name": full, + "type": ftype, + "value": _field_value(field), + } + entry.update(_field_options(field, ftype)) + + # Page lookup via /P indirect reference + p_ref = field.get("/P") + if p_ref and hasattr(p_ref, "idnum"): + entry["page"] = page_map.get(p_ref.idnum, "?") + + result.append(entry) + return result + + +def inspect(pdf_path: str) -> dict: + try: + reader = PdfReader(pdf_path) + except Exception as e: + return {"status": "error", "error": str(e)} + + # Build page-number lookup: {object_id: 1-based page number} + page_map = {} + for i, page in enumerate(reader.pages): + if hasattr(page, "indirect_reference") and page.indirect_reference: + page_map[page.indirect_reference.idnum] = i + 1 + + acroform = reader.trailer.get("/Root", {}).get("/AcroForm") + if acroform is None or "/Fields" not in acroform: + return { + "status": "ok", + "has_fields": False, + "field_count": 0, + "fields": [], + "note": "This PDF has no fillable form fields.", + } + + fields = _walk_fields(list(acroform["/Fields"]), page_map) + + return { + "status": "ok", + "has_fields": bool(fields), + "field_count": len(fields), + "fields": fields, + } + + +def main(): + parser = argparse.ArgumentParser(description="Inspect PDF form fields") + parser.add_argument("--input", required=True, help="PDF file to inspect") + parser.add_argument("--out", default="", help="Write JSON to file (optional)") + args = parser.parse_args() + + if not os.path.exists(args.input): + print(json.dumps({"status": "error", "error": f"File not found: {args.input}"}), + file=sys.stderr) + sys.exit(1) + + result = inspect(args.input) + + output = json.dumps(result, indent=2, ensure_ascii=False) + + if args.out: + with open(args.out, "w") as f: + f.write(output) + + print(output) + + # Human-readable summary + if result["status"] == "ok" and result["has_fields"]: + print(f"\n── Fields in {args.input} ──────────────────────────────", + file=sys.stderr) + for f in result["fields"]: + pg = f" p.{f['page']}" if "page" in f else "" + val = f" = {f['value']}" if f.get("value") else "" + extra = "" + if "choices" in f: + extra = f" [{', '.join(c['value'] for c in f['choices'][:4])}{'…' if len(f['choices'])>4 else ''}]" + elif "states" in f: + extra = f" {f['states']}" + print(f" {f['type']:12} {f['name']}{pg}{val}{extra}", file=sys.stderr) + print("", file=sys.stderr) + + +if __name__ == "__main__": + main() diff --git a/backend/app/skills_builtin/minimax-pdf/scripts/fill_write.py b/backend/app/skills_builtin/minimax-pdf/scripts/fill_write.py new file mode 100644 index 0000000..3ce1523 --- /dev/null +++ b/backend/app/skills_builtin/minimax-pdf/scripts/fill_write.py @@ -0,0 +1,242 @@ +#!/usr/bin/env python3 +""" +fill_write.py — Write values into PDF form fields. + +Usage: + # From a JSON data file + python3 fill_write.py --input form.pdf --data values.json --out filled.pdf + + # Inline JSON + python3 fill_write.py --input form.pdf --out filled.pdf \ + --values '{"FirstName": "Jane", "Agree": "true"}' + +values format: + { + "FieldName": "text value", # text field + "CheckBox1": "true", # checkbox (true / false) + "Dropdown1": "OptionValue", # dropdown (must match an existing choice value) + "Radio1": "/Choice2" # radio (must match a radio value) + } + +Exit codes: 0 success, 1 bad args, 2 dep missing, 3 read/write error, 4 validation error +""" + +import argparse +import json +import os +import sys +import importlib.util + + + + +def ensure_deps(): + if importlib.util.find_spec("pypdf") is None: + import subprocess + subprocess.check_call( + [sys.executable, "-m", "pip", "install", "--break-system-packages", "-q", "pypdf"] + ) + + +ensure_deps() +from pypdf import PdfReader, PdfWriter +from pypdf.generic import NameObject, TextStringObject, BooleanObject + + +# ── Field helpers ───────────────────────────────────────────────────────────── +def _field_type(field) -> str: + ft = str(field.get("/FT", "")) + if ft == "/Tx": return "text" + if ft == "/Btn": + ff = int(field.get("/Ff", 0)) + return "radio" if ff & (1 << 15) else "checkbox" + if ft == "/Ch": + ff = int(field.get("/Ff", 0)) + return "dropdown" if ff & (1 << 17) else "listbox" + return "unknown" + + +def _get_checkbox_on_value(field) -> str: + """Return the /AP /N key that means 'checked' (anything except /Off).""" + ap = field.get("/AP") + if ap and "/N" in ap: + for k in ap["/N"]: + if str(k) != "/Off": + return str(k) + return "/Yes" + + +def _get_dropdown_values(field) -> list[str]: + opt = field.get("/Opt") + if not opt: + return [] + values = [] + for item in opt: + try: + from pypdf.generic import ArrayObject + if isinstance(item, (list, ArrayObject)) and len(item) >= 1: + values.append(str(item[0])) + else: + values.append(str(item)) + except Exception: + values.append(str(item)) + return values + + +# ── Walk + fill ─────────────────────────────────────────────────────────────── +def _walk_and_fill(fields, data: dict, filled: list, errors: list, parent: str = ""): + for field in fields: + name = str(field.get("/T", "")) + full = f"{parent}.{name}" if parent else name + + # Recurse into named groups + kids = field.get("/Kids") + if kids: + named = [k for k in kids if "/T" in k] + if named: + _walk_and_fill(named, data, filled, errors, full) + continue + + if full not in data: + continue + + value = data[full] + ftype = _field_type(field) + + if ftype == "text": + field.update({ + NameObject("/V"): TextStringObject(str(value)), + NameObject("/DV"): TextStringObject(str(value)), + }) + filled.append(full) + + elif ftype == "checkbox": + truthy = str(value).lower() in ("true", "1", "yes", "on") + on_val = _get_checkbox_on_value(field) + pdf_val = on_val if truthy else "/Off" + field.update({ + NameObject("/V"): NameObject(pdf_val), + NameObject("/AS"): NameObject(pdf_val), + }) + filled.append(full) + + elif ftype in ("dropdown", "listbox"): + allowed = _get_dropdown_values(field) + if allowed and str(value) not in allowed: + errors.append({ + "field": full, + "error": f"Value '{value}' not in allowed choices: {allowed}" + }) + continue + field.update({NameObject("/V"): TextStringObject(str(value))}) + filled.append(full) + + elif ftype == "radio": + # Radio value must start with / + pdf_val = str(value) if str(value).startswith("/") else f"/{value}" + field.update({ + NameObject("/V"): NameObject(pdf_val), + NameObject("/AS"): NameObject(pdf_val), + }) + filled.append(full) + + else: + errors.append({"field": full, "error": f"Unsupported field type: {ftype}"}) + + +def fill(pdf_path: str, out_path: str, data: dict) -> dict: + try: + reader = PdfReader(pdf_path) + except Exception as e: + return {"status": "error", "error": str(e)} + + writer = PdfWriter() + writer.clone_document_from_reader(reader) + + acroform = writer._root_object.get("/AcroForm") # type: ignore[attr-defined] + if acroform is None or "/Fields" not in acroform: + return { + "status": "error", + "error": "This PDF has no fillable form fields.", + "hint": "Run fill_inspect.py first to confirm the PDF has fields.", + } + + # Enable appearance regeneration so viewers show the new values + acroform.update({NameObject("/NeedAppearances"): BooleanObject(True)}) + + filled: list[str] = [] + errors: list[dict] = [] + _walk_and_fill(list(acroform["/Fields"]), data, filled, errors) + + # Warn about requested fields that were never found + not_found = [k for k in data if k not in filled and not any(e["field"] == k for e in errors)] + + try: + os.makedirs(os.path.dirname(os.path.abspath(out_path)), exist_ok=True) + with open(out_path, "wb") as f: + writer.write(f) + except Exception as e: + return {"status": "error", "error": f"Write failed: {e}"} + + result = { + "status": "ok", + "out": out_path, + "filled_count": len(filled), + "filled_fields": filled, + "size_kb": os.path.getsize(out_path) // 1024, + } + if errors: + result["validation_errors"] = errors + if not_found: + result["not_found"] = not_found + result["hint"] = "Run fill_inspect.py to see all available field names." + return result + + +def main(): + parser = argparse.ArgumentParser(description="Fill PDF form fields") + parser.add_argument("--input", required=True, help="Input PDF with form fields") + parser.add_argument("--out", required=True, help="Output PDF path") + group = parser.add_mutually_exclusive_group(required=True) + group.add_argument("--data", help="Path to JSON file with field values") + group.add_argument("--values", help="Inline JSON string with field values") + args = parser.parse_args() + + if not os.path.exists(args.input): + print(json.dumps({"status": "error", "error": f"File not found: {args.input}"}), + file=sys.stderr) + sys.exit(1) + + # Load data + try: + if args.data: + with open(args.data) as f: + data = json.load(f) + else: + data = json.loads(args.values) + except Exception as e: + print(json.dumps({"status": "error", "error": f"JSON parse error: {e}"}), + file=sys.stderr) + sys.exit(1) + + result = fill(args.input, args.out, data) + print(json.dumps(result, indent=2, ensure_ascii=False)) + + if result["status"] == "ok": + print(f"\n── Fill complete ───────────────────────────────────────", + file=sys.stderr) + print(f" Output : {result['out']}", file=sys.stderr) + print(f" Filled : {result['filled_count']} field(s)", file=sys.stderr) + if result.get("validation_errors"): + print(f" Errors :", file=sys.stderr) + for e in result["validation_errors"]: + print(f" • {e['field']}: {e['error']}", file=sys.stderr) + if result.get("not_found"): + print(f" Not found: {result['not_found']}", file=sys.stderr) + print("", file=sys.stderr) + else: + sys.exit(3) + + +if __name__ == "__main__": + main() diff --git a/backend/app/skills_builtin/minimax-pdf/scripts/make.sh b/backend/app/skills_builtin/minimax-pdf/scripts/make.sh new file mode 100644 index 0000000..0b1730b --- /dev/null +++ b/backend/app/skills_builtin/minimax-pdf/scripts/make.sh @@ -0,0 +1,491 @@ +#!/usr/bin/env bash +# make.sh — minimax-pdf unified CLI +# Usage: bash make.sh [options] +# +# Commands: +# check Verify all dependencies +# fix Auto-install missing dependencies +# run --title T --type TYPE Full pipeline → output.pdf +# --out FILE Output path (default: output.pdf) +# --author A --date D +# --subtitle S +# --abstract A Optional abstract text for cover +# --cover-image URL Optional cover image URL/path +# --content FILE Path to content.json (optional) +# demo Build a full-featured demo to demo.pdf +# +# Document types: +# report proposal resume portfolio academic general +# minimal stripe diagonal frame editorial +# magazine darkroom terminal poster +# +# Content block types: +# h1 h2 h3 body bullet numbered callout table +# image figure code math chart flowchart bibliography +# divider caption pagebreak spacer +# +# Exit codes: 0 success, 1 usage error, 2 dep missing, 3 runtime error + +set -euo pipefail +SCRIPTS="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PY="python3" +NODE="node" + +# ── Colour helpers ───────────────────────────────────────────────────────────── +red() { printf '\033[0;31m%s\033[0m\n' "$*"; } +green() { printf '\033[0;32m%s\033[0m\n' "$*"; } +yellow() { printf '\033[0;33m%s\033[0m\n' "$*"; } +bold() { printf '\033[1m%s\033[0m\n' "$*"; } + +# ── check ────────────────────────────────────────────────────────────────────── +cmd_check() { + local ok=true + bold "Checking dependencies..." + + # Python + if command -v python3 &>/dev/null; then + green " ✓ python3 $(python3 --version 2>&1 | awk '{print $2}')" + else + red " ✗ python3 not found" + ok=false + fi + + # reportlab + if python3 -c "import reportlab" 2>/dev/null; then + green " ✓ reportlab" + else + yellow " ⚠ reportlab not installed (run: make.sh fix)" + ok=false + fi + + # pypdf + if python3 -c "import pypdf" 2>/dev/null; then + green " ✓ pypdf" + else + yellow " ⚠ pypdf not installed (run: make.sh fix)" + ok=false + fi + + # Node.js + if command -v node &>/dev/null; then + green " ✓ node $(node --version)" + else + red " ✗ node not found — cover rendering unavailable" + ok=false + fi + + # Playwright + if node -e "require('playwright')" 2>/dev/null || \ + node -e "require(require('child_process').execSync('npm root -g').toString().trim()+'/playwright')" 2>/dev/null; then + green " ✓ playwright" + else + yellow " ⚠ playwright not found (run: make.sh fix)" + ok=false + fi + + # matplotlib (optional — required for math/chart/flowchart; degrades gracefully) + if python3 -c "import matplotlib" 2>/dev/null; then + green " ✓ matplotlib (math, chart, flowchart blocks enabled)" + else + yellow " ⚠ matplotlib not installed — math/chart/flowchart blocks degrade to text (run: make.sh fix)" + fi + + if $ok; then + green "\nAll dependencies satisfied." + exit 0 + else + yellow "\nSome dependencies missing. Run: bash make.sh fix" + exit 2 + fi +} + +# ── fix ──────────────────────────────────────────────────────────────────────── +cmd_fix() { + bold "Installing missing dependencies..." + local rc=0 + + # Python packages + if command -v python3 &>/dev/null; then + python3 -m pip install --break-system-packages -q reportlab pypdf matplotlib 2>/dev/null \ + || python3 -m pip install -q reportlab pypdf matplotlib 2>/dev/null \ + || { yellow " pip install failed — try: pip install reportlab pypdf matplotlib"; rc=3; } + green " ✓ Python packages installed (reportlab, pypdf, matplotlib)" + fi + + # Playwright + if command -v npm &>/dev/null; then + npm install -g playwright --silent 2>/dev/null && \ + npx playwright install chromium --silent 2>/dev/null && \ + green " ✓ Playwright + Chromium installed" || \ + { yellow " playwright install failed — try manually"; rc=3; } + else + yellow " npm not found — cannot install Playwright automatically" + rc=2 + fi + + if [[ $rc -eq 0 ]]; then + green "\nAll dependencies installed. Run: bash make.sh check" + fi + exit $rc +} + +# ── run ──────────────────────────────────────────────────────────────────────── +cmd_run() { + local title="Untitled Document" + local type="general" + local author="" + local date="" + local subtitle="" + local abstract="" + local cover_image="" + local accent="" + local cover_bg="" + local content_file="" + local out="output.pdf" + local workdir + workdir="$(mktemp -d)" + + # Parse options + while [[ $# -gt 0 ]]; do + case "$1" in + --title) title="$2"; shift 2 ;; + --type) type="$2"; shift 2 ;; + --author) author="$2"; shift 2 ;; + --date) date="$2"; shift 2 ;; + --subtitle) subtitle="$2"; shift 2 ;; + --abstract) abstract="$2"; shift 2 ;; + --cover-image) cover_image="$2"; shift 2 ;; + --accent) accent="$2"; shift 2 ;; + --cover-bg) cover_bg="$2"; shift 2 ;; + --content) content_file="$2"; shift 2 ;; + --out) out="$2"; shift 2 ;; + *) echo "Unknown option: $1"; exit 1 ;; + esac + done + + bold "Building: $title" + echo " Type : $type" + echo " Output : $out" + + # Step 1: tokens + echo "" + bold "Step 1/4 Generating design tokens..." + local accent_args=() + [[ -n "$accent" ]] && accent_args+=(--accent "$accent") + [[ -n "$cover_bg" ]] && accent_args+=(--cover-bg "$cover_bg") + $PY "$SCRIPTS/palette.py" \ + --title "$title" --type "$type" \ + --author "$author" --date "$date" \ + --out "$workdir/tokens.json" \ + "${accent_args[@]+"${accent_args[@]}"}" + + # Inject optional cover fields into tokens.json + if [[ -n "$abstract" || -n "$cover_image" ]]; then + PDF_ABSTRACT="$abstract" PDF_COVER_IMAGE="$cover_image" PDF_TOKENS="$workdir/tokens.json" \ + $PY - <<'PYEOF' +import json, os +with open(os.environ["PDF_TOKENS"]) as f: + t = json.load(f) +abstract = os.environ.get("PDF_ABSTRACT", "") +cover_image = os.environ.get("PDF_COVER_IMAGE", "") +if abstract: + t["abstract"] = abstract +if cover_image: + t["cover_image"] = cover_image +with open(os.environ["PDF_TOKENS"], "w") as f: + json.dump(t, f, indent=2) +PYEOF + fi + + cat "$workdir/tokens.json" | $PY -c " +import json,sys +t=json.load(sys.stdin) +print(f' Mood : {t[\"mood\"]}') +print(f' Pattern : {t[\"cover_pattern\"]}') +print(f' Fonts : {t[\"font_display\"]} / {t[\"font_body\"]}')" + + # Step 2: cover HTML + render + echo "" + bold "Step 2/4 Rendering cover..." + local subtitle_args=() + [[ -n "$subtitle" ]] && subtitle_args=(--subtitle "$subtitle") + $PY "$SCRIPTS/cover.py" \ + --tokens "$workdir/tokens.json" \ + --out "$workdir/cover.html" \ + "${subtitle_args[@]+"${subtitle_args[@]}"}" + + $NODE "$SCRIPTS/render_cover.js" \ + --input "$workdir/cover.html" \ + --out "$workdir/cover.pdf" + green " ✓ Cover rendered" + + # Step 3: body + echo "" + bold "Step 3/4 Rendering body pages..." + if [[ -z "$content_file" ]]; then + # Generate a minimal placeholder body + cat > "$workdir/content.json" <<'JSON' +[ + {"type":"h1", "text":"Document Body"}, + {"type":"body", "text":"Replace this with your content.json file using --content path/to/content.json"}, + {"type":"body", "text":"See the content.json schema in the skill README for the full list of supported block types: h1, h2, h3, body, bullet, callout, table, pagebreak, spacer."} +] +JSON + content_file="$workdir/content.json" + yellow " No content file provided — using placeholder body." + fi + + $PY "$SCRIPTS/render_body.py" \ + --tokens "$workdir/tokens.json" \ + --content "$content_file" \ + --out "$workdir/body.pdf" + green " ✓ Body rendered" + + # Step 4: merge + echo "" + bold "Step 4/4 Merging and QA..." + $PY "$SCRIPTS/merge.py" \ + --cover "$workdir/cover.pdf" \ + --body "$workdir/body.pdf" \ + --out "$out" \ + --title "$title" + + # Cleanup + rm -rf "$workdir" +} + +# ── fill ────────────────────────────────────────────────────────────────────── +cmd_fill() { + local input="" out="" values="" data_file="" inspect_only=false + + while [[ $# -gt 0 ]]; do + case "$1" in + --input) input="$2"; shift 2 ;; + --out) out="$2"; shift 2 ;; + --values) values="$2"; shift 2 ;; + --data) data_file="$2"; shift 2 ;; + --inspect) inspect_only=true; shift ;; + *) echo "Unknown option: $1"; exit 1 ;; + esac + done + + if [[ -z "$input" ]]; then + echo "Usage: make.sh fill --input form.pdf [--out filled.pdf] [--values '{...}'] [--data values.json] [--inspect]" + exit 1 + fi + + if $inspect_only || [[ -z "$out" && -z "$values" && -z "$data_file" ]]; then + bold "Inspecting form fields in: $input" + $PY "$SCRIPTS/fill_inspect.py" --input "$input" + return + fi + + bold "Filling form: $input → $out" + + local val_args="" + if [[ -n "$values" ]]; then val_args="--values $values"; fi + if [[ -n "$data_file" ]]; then val_args="--data $data_file"; fi + + $PY "$SCRIPTS/fill_write.py" --input "$input" --out "$out" $val_args +} + +# ── reformat ─────────────────────────────────────────────────────────────────── +cmd_reformat() { + local input="" title="Reformatted Document" type="general" + local author="" date="" out="output.pdf" subtitle="" + local tmpdir + tmpdir="$(mktemp -d)" + + while [[ $# -gt 0 ]]; do + case "$1" in + --input) input="$2"; shift 2 ;; + --title) title="$2"; shift 2 ;; + --type) type="$2"; shift 2 ;; + --author) author="$2"; shift 2 ;; + --date) date="$2"; shift 2 ;; + --subtitle) subtitle="$2"; shift 2 ;; + --out) out="$2"; shift 2 ;; + *) echo "Unknown option: $1"; exit 1 ;; + esac + done + + if [[ -z "$input" ]]; then + echo "Usage: make.sh reformat --input source.md --title T --type TYPE --out output.pdf" + exit 1 + fi + + bold "Parsing: $input" + $PY "$SCRIPTS/reformat_parse.py" --input "$input" --out "$tmpdir/content.json" + green " ✓ Parsed to content.json" + + bold "Applying design and building PDF..." + local sub_args=() + [[ -n "$subtitle" ]] && sub_args=(--subtitle "$subtitle") + + cmd_run \ + --title "$title" --type "$type" \ + --author "$author" --date "$date" \ + --content "$tmpdir/content.json" \ + --out "$out" \ + "${sub_args[@]+"${sub_args[@]}"}" + + rm -rf "$tmpdir" +} + +# ── demo ────────────────────────────────────────────────────────────────────── +cmd_demo() { + local tmpdir + tmpdir="$(mktemp -d)" + + cat > "$tmpdir/content.json" <<'JSON' +[ + {"type":"h1", "text":"Executive Summary"}, + {"type":"body", "text":"This document was generated by minimax-pdf — a skill for creating visually polished PDFs. Every design decision is rooted in the document type and content, not a generic template."}, + {"type":"callout", "text":"Key insight: design tokens flow from palette.py through every renderer, keeping cover and body visually consistent."}, + + {"type":"h1", "text":"How It Works"}, + {"type":"h2", "text":"The Token Pipeline"}, + {"type":"body", "text":"The palette.py script infers a color palette and typography pair from the document type. These tokens are written to tokens.json and consumed by every downstream script."}, + {"type":"numbered","text":"palette.py generates color tokens, font selection, and the cover pattern"}, + {"type":"numbered","text":"cover.py renders the cover HTML using the selected pattern"}, + {"type":"numbered","text":"render_cover.js uses Playwright to convert the HTML cover to PDF"}, + {"type":"numbered","text":"render_body.py builds inner pages from content.json using ReportLab"}, + {"type":"numbered","text":"merge.py combines cover + body and runs final QA checks"}, + + {"type":"h2", "text":"Cover Patterns"}, + {"type":"table", + "headers": ["Pattern", "Document type", "Visual character"], + "rows": [ + ["fullbleed", "report, general", "Deep background · dot-grid texture"], + ["split", "proposal", "Left dark panel · right dot-grid"], + ["typographic", "resume, academic", "Oversized display type · first-word accent"], + ["atmospheric", "portfolio", "Dark bg · radial glow · dot-grid"], + ["magazine", "magazine", "Cream bg · centered · hero image"], + ["darkroom", "darkroom", "Navy bg · centered · grayscale image"], + ["terminal", "terminal", "Near-black · grid lines · monospace"], + ["poster", "poster", "White · thick sidebar · oversized title"] + ] + }, + + {"type":"h1", "text":"Data Visualisation"}, + {"type":"h2", "text":"Performance Metrics (Chart)"}, + {"type":"body", "text":"Charts are rendered natively using matplotlib with a color palette derived from the document accent. No external chart services or image files required."}, + {"type":"chart", + "chart_type": "bar", + "title": "Quarterly Performance", + "labels": ["Q1", "Q2", "Q3", "Q4"], + "datasets": [ + {"label": "Revenue", "values": [120, 145, 132, 178]}, + {"label": "Expenses", "values": [95, 108, 99, 122]} + ], + "y_label": "USD (thousands)", + "caption": "Quarterly revenue vs. expenses" + }, + + {"type":"h2", "text":"Market Share (Pie Chart)"}, + {"type":"chart", + "chart_type": "pie", + "labels": ["Product A", "Product B", "Product C", "Other"], + "datasets": [{"values": [42, 28, 18, 12]}], + "caption": "Annual market share by product line" + }, + + {"type":"pagebreak"}, + + {"type":"h1", "text":"Mathematics"}, + {"type":"body", "text":"Display math is rendered via matplotlib mathtext — no LaTeX binary installation required. Inline references use standard [N] notation in body text."}, + {"type":"math", "text":"E = mc^2", "label":"(1)"}, + {"type":"math", "text":"\\int_0^\\infty e^{-x^2}\\,dx = \\frac{\\sqrt{\\pi}}{2}", "label":"(2)"}, + {"type":"math", "text":"\\sum_{n=1}^{\\infty} \\frac{1}{n^2} = \\frac{\\pi^2}{6}", "caption":"Basel problem (Euler, 1734)"}, + + {"type":"h1", "text":"Process Flow"}, + {"type":"body", "text":"Flowcharts are drawn directly using matplotlib patches — no Graphviz or external tools needed. Supported node shapes: rect, diamond, oval, parallelogram."}, + {"type":"flowchart", + "nodes": [ + {"id":"start", "label":"Start", "shape":"oval"}, + {"id":"input", "label":"Receive Input", "shape":"parallelogram"}, + {"id":"valid", "label":"Valid?", "shape":"diamond"}, + {"id":"proc", "label":"Process Data", "shape":"rect"}, + {"id":"err", "label":"Return Error", "shape":"rect"}, + {"id":"out", "label":"Return Result", "shape":"parallelogram"}, + {"id":"end", "label":"End", "shape":"oval"} + ], + "edges": [ + {"from":"start", "to":"input"}, + {"from":"input", "to":"valid"}, + {"from":"valid", "to":"proc", "label":"Yes"}, + {"from":"valid", "to":"err", "label":"No"}, + {"from":"proc", "to":"out"}, + {"from":"err", "to":"end"}, + {"from":"out", "to":"end"} + ], + "caption": "Data validation and processing flow" + }, + + {"type":"h1", "text":"Code Example"}, + {"type":"code", "language":"python", + "text":"# Design token pipeline\ntokens = palette.build_tokens(\n title=\"Annual Report\",\n doc_type=\"report\",\n author=\"J. Smith\",\n date=\"March 2026\",\n)\nhtml = cover.render(tokens)\npdf = render_cover(html)"}, + + {"type":"h1", "text":"Design Principles"}, + {"type":"body", "text":"The aesthetic system is documented in design/design.md. The core rule: every design decision must be rooted in the document content and purpose. A color chosen because it fits the content will always outperform a color chosen because it seems safe."}, + {"type":"h2", "text":"Restraint over decoration"}, + {"type":"body", "text":"The page is done when there is nothing left to remove. Accent color appears on section rules only — not on headings, not on bullets. No card components, no drop shadows."}, + {"type":"callout", "text":"A PDF passes the quality bar when a designer would not be embarrassed to hand it to a client."}, + + {"type":"pagebreak"}, + {"type":"bibliography", + "title": "References", + "items": [ + {"id":"1","text":"Bringhurst, R. (2004). The Elements of Typographic Style (3rd ed.). Hartley & Marks."}, + {"id":"2","text":"Cairo, A. (2016). The Truthful Art: Data, Charts, and Maps for Communication. New Riders."}, + {"id":"3","text":"Hochuli, J. & Kinross, R. (1996). Designing Books: Practice and Theory. Hyphen Press."} + ] + } +] +JSON + + cmd_run \ + --title "minimax-pdf demo" \ + --type "report" \ + --author "minimax-pdf skill" \ + --date "$(date '+%B %Y')" \ + --subtitle "A demonstration of the token-based design pipeline" \ + --content "$tmpdir/content.json" \ + --out "demo.pdf" + + rm -rf "$tmpdir" +} + +# ── dispatch ─────────────────────────────────────────────────────────────────── +main() { + if [[ $# -lt 1 ]]; then + bold "minimax-pdf — make.sh" + echo "" + echo "Usage: bash make.sh [options]" + echo "" + echo "Commands:" + echo " check Verify all dependencies" + echo " fix Auto-install missing deps" + echo " run --title T --type TYPE CREATE: full pipeline → PDF" + echo " [--author A] [--date D] [--subtitle S]" + echo " [--abstract A] [--cover-image URL]" + echo " [--accent #HEX] [--cover-bg #HEX]" + echo " [--content content.json] [--out output.pdf]" + echo " fill --input f.pdf FILL: inspect or fill form fields" + echo " reformat --input doc.md REFORMAT: parse doc → apply design → PDF" + echo " demo Build a full-featured demo PDF" + exit 0 + fi + + case "$1" in + check) cmd_check ;; + fix) cmd_fix ;; + run) shift; cmd_run "$@" ;; + fill) shift; cmd_fill "$@" ;; + reformat) shift; cmd_reformat "$@" ;; + demo) cmd_demo ;; + *) echo "Unknown command: $1"; exit 1 ;; + esac +} + +main "$@" diff --git a/backend/app/skills_builtin/minimax-pdf/scripts/merge.py b/backend/app/skills_builtin/minimax-pdf/scripts/merge.py new file mode 100644 index 0000000..7bf68ee --- /dev/null +++ b/backend/app/skills_builtin/minimax-pdf/scripts/merge.py @@ -0,0 +1,112 @@ +#!/usr/bin/env python3 +""" +merge.py — Merge cover.pdf + body.pdf → final.pdf and print a QA report. + +Usage: + python3 merge.py --cover cover.pdf --body body.pdf --out final.pdf + python3 merge.py --cover cover.pdf --body body.pdf --out final.pdf --title "My Report" + +Exit codes: 0 success, 1 bad args/missing file, 2 missing dep, 3 merge error +""" + +import argparse +import importlib.util +import json +import os +import sys + +def ensure_deps(): + if importlib.util.find_spec("pypdf") is None: + import subprocess + subprocess.check_call( + [sys.executable, "-m", "pip", "install", "--break-system-packages", "-q", "pypdf"] + ) + + +ensure_deps() + +from pypdf import PdfWriter, PdfReader + + +def merge(cover_path: str, body_path: str, out_path: str, title: str = "") -> dict: + writer = PdfWriter() + + for fpath, label in [(cover_path, "cover"), (body_path, "body")]: + if not os.path.exists(fpath): + return {"status": "error", "error": f"{label} file not found: {fpath}"} + reader = PdfReader(fpath) + for page in reader.pages: + writer.add_page(page) + + # Set PDF metadata + if title: + writer.add_metadata({"/Title": title}) + + os.makedirs(os.path.dirname(os.path.abspath(out_path)), exist_ok=True) + with open(out_path, "wb") as f: + writer.write(f) + + size_kb = os.path.getsize(out_path) // 1024 + total_pages = len(writer.pages) + + # ── QA checks ───────────────────────────────────────────────────────────── + warnings = [] + + # Page count sanity + cover_pages = len(PdfReader(cover_path).pages) + body_pages = len(PdfReader(body_path).pages) + if cover_pages != 1: + warnings.append(f"Cover PDF has {cover_pages} pages (expected 1)") + + # File size sanity + if size_kb < 20: + warnings.append(f"Output is very small ({size_kb} KB) — may have blank pages") + if size_kb > 50_000: + warnings.append(f"Output is very large ({size_kb} KB) — consider compressing images") + + report = { + "status": "ok", + "out": out_path, + "total_pages": total_pages, + "cover_pages": cover_pages, + "body_pages": body_pages, + "size_kb": size_kb, + } + if warnings: + report["warnings"] = warnings + + return report + + +def main(): + parser = argparse.ArgumentParser(description="Merge cover + body PDFs") + parser.add_argument("--cover", required=True) + parser.add_argument("--body", required=True) + parser.add_argument("--out", required=True) + parser.add_argument("--title", default="") + args = parser.parse_args() + + result = merge(args.cover, args.body, args.out, args.title) + + if result["status"] == "error": + print(json.dumps(result), file=sys.stderr) + sys.exit(3) + + print(json.dumps(result)) + + # Human-readable QA summary + print(f"\n── Build complete ──────────────────────────────────────") + print(f" Output : {result['out']}") + print(f" Pages : {result['total_pages']} total (1 cover + {result['body_pages']} body)") + print(f" Size : {result['size_kb']} KB") + if result.get("warnings"): + print(f" ⚠ Warnings:") + for w in result["warnings"]: + print(f" • {w}") + else: + print(f" ✓ No issues detected") + print(f"────────────────────────────────────────────────────────\n") + + +if __name__ == "__main__": + main() diff --git a/backend/app/skills_builtin/minimax-pdf/scripts/palette.py b/backend/app/skills_builtin/minimax-pdf/scripts/palette.py new file mode 100644 index 0000000..9988aff --- /dev/null +++ b/backend/app/skills_builtin/minimax-pdf/scripts/palette.py @@ -0,0 +1,521 @@ +#!/usr/bin/env python3 +""" +palette.py — Infer design tokens from document metadata. + +Usage: + python3 palette.py --title "AI Trends 2025" --type report --out tokens.json + python3 palette.py --title "John Doe Resume" --type resume --out tokens.json + python3 palette.py --meta meta.json --out tokens.json + +Outputs tokens.json consumed by all downstream scripts. +Cover fonts are loaded via Google Fonts @import in the cover HTML (no local caching). +Body fonts always use ReportLab system fonts (Times-Bold / Helvetica). +Exit codes: 0 success, 1 bad args, 3 write error +""" + +import argparse +import json +import sys + +# ── Palette library ──────────────────────────────────────────────────────────── +# Each entry: cover colors + cover_pattern + mood +PALETTES = { + "report": { + # Charcoal blue-grey cover; muted steel blue accent — authoritative, not flashy + "cover_bg": "#1B2A38", + "accent": "#3B6D8A", + "accent_lt": "#E6EFF5", + "text_light": "#EDE9E2", + "page_bg": "#FAFAF8", + "dark": "#1A1E24", + "body_text": "#2C2C30", + "muted": "#7A7A84", + "cover_pattern": "fullbleed", + "mood": "authoritative", + }, + "proposal": { + # Dark charcoal cover; slate grey-blue accent — confident, understated + "cover_bg": "#22272E", + "accent": "#4E6070", + "accent_lt": "#EAECEE", + "text_light": "#EDE9E2", + "page_bg": "#FAFAF7", + "dark": "#18191E", + "body_text": "#28282E", + "muted": "#7A7870", + "cover_pattern": "split", + "mood": "confident", + }, + "resume": { + # White; deep navy accent — clean and unambiguous + "cover_bg": "#FFFFFF", + "accent": "#1C3557", + "accent_lt": "#E8EEF5", + "text_light": "#FFFFFF", + "page_bg": "#FFFFFF", + "dark": "#111111", + "body_text": "#222222", + "muted": "#888888", + "cover_pattern": "typographic", + "mood": "clean", + }, + "portfolio": { + # Near-black charcoal; cool slate grey accent — subdued professional + "cover_bg": "#191C20", + "accent": "#6A7A88", + "accent_lt": "#EAECEE", + "text_light": "#EDE9E4", + "page_bg": "#F8F8F8", + "dark": "#18191E", + "body_text": "#28282E", + "muted": "#8A8A96", + "cover_pattern": "atmospheric", + "mood": "expressive", + }, + "academic": { + # Warm white; classic navy accent — scholarly standard + "cover_bg": "#F5F4F0", + "accent": "#2A436A", + "accent_lt": "#E6EBF4", + "text_light": "#FFFFFF", + "page_bg": "#F5F4F0", + "dark": "#1A1A28", + "body_text": "#1E1E2A", + "muted": "#686877", + "cover_pattern": "typographic", + "mood": "scholarly", + }, + "general": { + # Dark slate; muted steel accent — neutral, no-nonsense + "cover_bg": "#1F2329", + "accent": "#4A6070", + "accent_lt": "#E6EAEC", + "text_light": "#EEEBE5", + "page_bg": "#F8F6F2", + "dark": "#1A1A1A", + "body_text": "#2C2C2C", + "muted": "#888888", + "cover_pattern": "fullbleed", + "mood": "neutral", + }, + # ── Extended types — each uses a distinct new cover pattern ───────────────── + "minimal": { + # Warm off-white; dark neutral grey — truly restrained, no color signal + "cover_bg": "#F7F6F4", + "accent": "#4A4A4A", + "accent_lt": "#EBEBEA", + "text_light": "#F7F6F4", + "page_bg": "#F7F6F4", + "dark": "#111111", + "body_text": "#222222", + "muted": "#999999", + "cover_pattern": "minimal", + "mood": "restrained", + }, + "stripe": { + # Near-black; charcoal slate accent — structured, no-nonsense + "cover_bg": "#1E222A", + "accent": "#4A5568", + "accent_lt": "#EAECEE", + "text_light": "#FFFFFF", + "page_bg": "#F8F8F7", + "dark": "#0E1117", + "body_text": "#262630", + "muted": "#888898", + "cover_pattern": "stripe", + "mood": "bold", + }, + "diagonal": { + # Deep navy; muted slate-blue accent — dignified, controlled + "cover_bg": "#1A2535", + "accent": "#3D5A72", + "accent_lt": "#E4EBF0", + "text_light": "#EEF0F5", + "page_bg": "#F8FAFC", + "dark": "#0F1A2A", + "body_text": "#1E2C3A", + "muted": "#7A8A96", + "cover_pattern": "diagonal", + "mood": "dynamic", + }, + "frame": { + # Warm parchment; dark muted brown — classical, formal + "cover_bg": "#F5F2EC", + "accent": "#5C4A38", + "accent_lt": "#EAE5DE", + "text_light": "#F5F2EC", + "page_bg": "#F5F2EC", + "dark": "#2A1E14", + "body_text": "#2C2018", + "muted": "#9A8A78", + "cover_pattern": "frame", + "mood": "classical", + }, + "editorial": { + # White; deep burgundy accent — editorial weight without the shout + "cover_bg": "#FFFFFF", + "accent": "#7A2B36", + "accent_lt": "#EEE4E5", + "text_light": "#FFFFFF", + "page_bg": "#FFFFFF", + "dark": "#0A0A0A", + "body_text": "#1A1A1A", + "muted": "#777777", + "cover_pattern": "editorial", + "mood": "editorial", + }, + # ── New patterns (v2) ──────────────────────────────────────────────────────── + "magazine": { + # Warm linen; deep navy accent — formal publication standard + "cover_bg": "#F0EEE9", + "accent": "#1C3557", + "accent_lt": "#E4EBF3", + "text_light": "#FFFFFF", + "page_bg": "#F0EEE9", + "dark": "#0D1A2B", + "body_text": "#2A2A2A", + "muted": "#888888", + "cover_pattern": "magazine", + "mood": "magazine", + }, + "darkroom": { + # Deep navy; muted steel-blue accent — premium, controlled + "cover_bg": "#151C27", + "accent": "#3D5A7A", + "accent_lt": "#E2EBF2", + "text_light": "#EDE9E2", + "page_bg": "#F7F7F5", + "dark": "#0A1018", + "body_text": "#2C2C2C", + "muted": "#8A9AB0", + "cover_pattern": "darkroom", + "mood": "darkroom", + }, + "terminal": { + # Near-black; forest green accent — technical, serious (not neon) + "cover_bg": "#0D1117", + "accent": "#3D7A5C", + "accent_lt": "#E2EEE8", + "text_light": "#E6EDF3", + "page_bg": "#F8F8F6", + "dark": "#010409", + "body_text": "#2C2C2C", + "muted": "#5A7A6A", + "cover_pattern": "terminal", + "mood": "terminal", + }, + "poster": { + # White; near-black accent sidebar — stark, unambiguous + "cover_bg": "#FFFFFF", + "accent": "#0A0A0A", + "accent_lt": "#EBEBEA", + "text_light": "#FFFFFF", + "page_bg": "#FFFFFF", + "dark": "#0A0A0A", + "body_text": "#1A1A1A", + "muted": "#888888", + "cover_pattern": "poster", + "mood": "poster", + }, +} + +# ── Font pairs — CSS names for cover HTML, ReportLab names for body ───────────── +# cover uses Google Fonts via @import (no local disk caching needed) +# body always uses system fonts via ReportLab +FONT_PAIRS = { + "authoritative": { + "display_css": "Playfair Display", + "body_css": "IBM Plex Sans", + "gfonts_import": "https://fonts.googleapis.com/css2?family=Playfair+Display:wght@700;900&family=IBM+Plex+Sans:ital,wght@0,400;0,600;1,400&display=swap", + "display_rl": "Times-Bold", + "body_rl": "Helvetica", + "body_b_rl": "Helvetica-Bold", + }, + "confident": { + "display_css": "Syne", + "body_css": "Nunito Sans", + "gfonts_import": "https://fonts.googleapis.com/css2?family=Syne:wght@600;800&family=Nunito+Sans:wght@400;600;700&display=swap", + "display_rl": "Times-Bold", + "body_rl": "Helvetica", + "body_b_rl": "Helvetica-Bold", + }, + "clean": { + "display_css": "DM Serif Display", + "body_css": "DM Sans", + "gfonts_import": "https://fonts.googleapis.com/css2?family=DM+Serif+Display&family=DM+Sans:wght@300;400;500&display=swap", + "display_rl": "Times-Bold", + "body_rl": "Helvetica", + "body_b_rl": "Helvetica-Bold", + }, + "expressive": { + "display_css": "Fraunces", + "body_css": "Inter", + "gfonts_import": "https://fonts.googleapis.com/css2?family=Fraunces:ital,wght@0,700;0,900;1,900&family=Inter:wght@300;400;500&display=swap", + "display_rl": "Times-Bold", + "body_rl": "Helvetica", + "body_b_rl": "Helvetica-Bold", + }, + "scholarly": { + "display_css": "EB Garamond", + "body_css": "Source Sans 3", + "gfonts_import": "https://fonts.googleapis.com/css2?family=EB+Garamond:ital,wght@0,400;0,700;1,400&family=Source+Sans+3:wght@400;600&display=swap", + "display_rl": "Times-Bold", + "body_rl": "Helvetica", + "body_b_rl": "Helvetica-Bold", + }, + "neutral": { + "display_css": "Outfit", + "body_css": "Outfit", + "gfonts_import": "https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;700;900&display=swap", + "display_rl": "Times-Bold", + "body_rl": "Helvetica", + "body_b_rl": "Helvetica-Bold", + }, + "restrained": { + "display_css": "Cormorant Garamond", + "body_css": "Jost", + "gfonts_import": "https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,300;0,600;1,300&family=Jost:wght@300;400;500&display=swap", + "display_rl": "Times-Bold", + "body_rl": "Helvetica", + "body_b_rl": "Helvetica-Bold", + }, + "bold": { + "display_css": "Barlow Condensed", + "body_css": "Barlow", + "gfonts_import": "https://fonts.googleapis.com/css2?family=Barlow+Condensed:wght@700;900&family=Barlow:wght@400;500;600&display=swap", + "display_rl": "Times-Bold", + "body_rl": "Helvetica", + "body_b_rl": "Helvetica-Bold", + }, + "dynamic": { + "display_css": "Montserrat", + "body_css": "Montserrat", + "gfonts_import": "https://fonts.googleapis.com/css2?family=Montserrat:ital,wght@0,300;0,700;0,900;1,400&display=swap", + "display_rl": "Times-Bold", + "body_rl": "Helvetica", + "body_b_rl": "Helvetica-Bold", + }, + "classical": { + "display_css": "Cormorant", + "body_css": "Crimson Pro", + "gfonts_import": "https://fonts.googleapis.com/css2?family=Cormorant:ital,wght@0,400;0,700;1,400&family=Crimson+Pro:wght@400;600&display=swap", + "display_rl": "Times-Bold", + "body_rl": "Helvetica", + "body_b_rl": "Helvetica-Bold", + }, + "editorial": { + "display_css": "Bebas Neue", + "body_css": "Libre Franklin", + "gfonts_import": ( + "https://fonts.googleapis.com/css2?family=Bebas+Neue" + "&family=Libre+Franklin:ital,wght@0,400;0,700;1,400&display=swap" + ), + "display_rl": "Times-Bold", + "body_rl": "Helvetica", + "body_b_rl": "Helvetica-Bold", + }, + # ── New moods (v2) ─────────────────────────────────────────────────────────── + "magazine": { + "display_css": "Playfair Display", + "body_css": "EB Garamond", + "gfonts_import": ( + "https://fonts.googleapis.com/css2?family=Playfair+Display" + ":ital,wght@0,700;0,900;1,700" + "&family=EB+Garamond:ital,wght@0,400;0,600;1,400&display=swap" + ), + "display_rl": "Times-Bold", + "body_rl": "Helvetica", + "body_b_rl": "Helvetica-Bold", + }, + "darkroom": { + "display_css": "Playfair Display", + "body_css": "EB Garamond", + "gfonts_import": ( + "https://fonts.googleapis.com/css2?family=Playfair+Display" + ":ital,wght@0,700;0,900;1,700" + "&family=EB+Garamond:ital,wght@0,400;0,600;1,400&display=swap" + ), + "display_rl": "Times-Bold", + "body_rl": "Helvetica", + "body_b_rl": "Helvetica-Bold", + }, + "terminal": { + "display_css": "Space Mono", + "body_css": "Space Mono", + "gfonts_import": ( + "https://fonts.googleapis.com/css2?family=Space+Mono" + ":ital,wght@0,400;0,700;1,400&display=swap" + ), + "display_rl": "Courier-Bold", + "body_rl": "Courier", + "body_b_rl": "Courier-Bold", + }, + "poster": { + "display_css": "Barlow Condensed", + "body_css": "Courier Prime", + "gfonts_import": ( + "https://fonts.googleapis.com/css2?family=Barlow+Condensed" + ":wght@700;900" + "&family=Courier+Prime:ital,wght@0,400;0,700;1,400&display=swap" + ), + "display_rl": "Times-Bold", + "body_rl": "Courier", + "body_b_rl": "Courier-Bold", + }, +} + +SYSTEM_FALLBACK = { + "display_css": "Georgia", + "body_css": "Arial", + "gfonts_import": "", + "display_rl": "Times-Bold", + "body_rl": "Helvetica", + "body_b_rl": "Helvetica-Bold", +} + + +# ── Colour helpers ────────────────────────────────────────────────────────────── +def _hex_to_rgb(h: str) -> tuple: + h = h.lstrip("#") + return int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16) + + +def _lighten(hex_color: str, factor: float = 0.09) -> str: + """Blend hex_color toward white (factor = accent weight, 0=white, 1=full color).""" + r, g, b = _hex_to_rgb(hex_color) + return "#{:02X}{:02X}{:02X}".format( + round(r * factor + 255 * (1 - factor)), + round(g * factor + 255 * (1 - factor)), + round(b * factor + 255 * (1 - factor)), + ) + + +# ── Token assembly ───────────────────────────────────────────────────────────── +def build_tokens( + title: str, + doc_type: str, + author: str = "", + date: str = "", + accent_override: str = "", + cover_bg_override: str = "", +) -> dict: + palette = PALETTES.get(doc_type, PALETTES["general"]).copy() + mood = palette["mood"] + font_pair = FONT_PAIRS.get(mood, SYSTEM_FALLBACK) + + # Apply caller-supplied overrides before token assembly + if accent_override: + palette["accent"] = accent_override + palette["accent_lt"] = _lighten(accent_override, 0.09) + if cover_bg_override: + palette["cover_bg"] = cover_bg_override + + tokens = { + # Identity + "title": title, + "author": author, + "date": date, + "doc_type": doc_type, + + # Palette + "cover_bg": palette["cover_bg"], + "accent": palette["accent"], + "accent_lt": palette["accent_lt"], + "text_light": palette["text_light"], + "page_bg": palette["page_bg"], + "dark": palette["dark"], + "body_text": palette["body_text"], + "muted": palette["muted"], + "cover_pattern": palette["cover_pattern"], + "mood": mood, + + # Typography — CSS names for cover HTML (loaded via Google Fonts @import) + "font_display": font_pair["display_css"], + "font_body": font_pair["body_css"], + "gfonts_import": font_pair["gfonts_import"], + + # Typography — ReportLab system font names for body pages + "font_display_rl": font_pair["display_rl"], + "font_body_rl": font_pair["body_rl"], + "font_body_b_rl": font_pair["body_b_rl"], + + # Legacy keys (kept so render_body.py's register_fonts is a no-op) + "font_heading": font_pair["display_rl"], + "font_body_b": font_pair["body_b_rl"], + "font_paths": {}, + + # Type scale (pt) + "size_display": 54, + "size_h1": 22, + "size_h2": 15, + "size_h3": 11.5, + "size_body": 10.5, + "size_caption": 8.5, + "size_meta": 8, + + # Layout (pt, 1cm ≈ 28.35pt) + "margin_left": 79, # 2.8cm + "margin_right": 79, + "margin_top": 79, + "margin_bottom": 71, # 2.5cm + "section_gap": 26, + "para_gap": 8, + "line_gap": 17, + } + return tokens + + +# ── CLI ─────────────────────────────────────────────────────────────────────── +def main(): + parser = argparse.ArgumentParser(description="Generate design tokens from document metadata") + parser.add_argument("--title", default="Untitled Document") + parser.add_argument("--type", default="general", + choices=list(PALETTES.keys()), + help="Document type: " + ", ".join(PALETTES.keys())) + parser.add_argument("--author", default="") + parser.add_argument("--date", default="") + parser.add_argument("--meta", help="JSON file with title/type/author/date keys") + parser.add_argument("--accent", default="", + help="Override accent colour (hex, e.g. #2D6A8F). " + "accent_lt is auto-derived by lightening toward white.") + parser.add_argument("--cover-bg", default="", + help="Override cover background colour (hex).") + parser.add_argument("--out", default="tokens.json") + args = parser.parse_args() + + if args.meta: + try: + with open(args.meta) as f: + meta = json.load(f) + args.title = meta.get("title", args.title) + args.type = meta.get("type", args.type) + args.author = meta.get("author", args.author) + args.date = meta.get("date", args.date) + except Exception as e: + print(json.dumps({"status": "error", "error": str(e)}), file=sys.stderr) + sys.exit(1) + + tokens = build_tokens( + args.title, args.type, args.author, args.date, + accent_override=args.accent, + cover_bg_override=getattr(args, "cover_bg", ""), + ) + + try: + with open(args.out, "w") as f: + json.dump(tokens, f, indent=2) + except Exception as e: + print(json.dumps({"status": "error", "error": str(e)}), file=sys.stderr) + sys.exit(3) + + print(json.dumps({ + "status": "ok", + "out": args.out, + "mood": tokens["mood"], + "pattern": tokens["cover_pattern"], + "fonts": f'{tokens["font_display"]} / {tokens["font_body"]}', + })) + + +if __name__ == "__main__": + main() diff --git a/backend/app/skills_builtin/minimax-pdf/scripts/reformat_parse.py b/backend/app/skills_builtin/minimax-pdf/scripts/reformat_parse.py new file mode 100644 index 0000000..be125d5 --- /dev/null +++ b/backend/app/skills_builtin/minimax-pdf/scripts/reformat_parse.py @@ -0,0 +1,374 @@ +#!/usr/bin/env python3 +""" +reformat_parse.py — Convert an existing document into content.json, +then hand off to the CREATE pipeline (render_body.py). + +Supported input formats: + .md / .txt — Markdown / plain text + .pdf — Extract text from existing PDF (layout preserved as best-effort) + .json — Pass-through if already content.json format + +Usage: + python3 reformat_parse.py --input doc.md --out content.json + python3 reformat_parse.py --input old.pdf --out content.json + python3 reformat_parse.py --input data.json --out content.json + +Then pipe into the CREATE pipeline: + python3 render_body.py --tokens tokens.json --content content.json --out body.pdf + +Or use make.sh reformat which does both steps: + bash make.sh reformat --input doc.md --type report --title "My Report" --out output.pdf + +Exit codes: 0 success, 1 bad args / unsupported format, 2 dep missing, 3 parse error +""" + +import argparse +import json +import os +import re +import sys +import importlib.util +from pathlib import Path + + + + +def ensure_deps(): + missing = [] + if importlib.util.find_spec("pypdf") is None: + missing.append("pypdf") + if missing: + import subprocess + subprocess.check_call( + [sys.executable, "-m", "pip", "install", "--break-system-packages", "-q"] + missing + ) + + +ensure_deps() + + +# ── Markdown / plain text parser ─────────────────────────────────────────────── +def parse_markdown(text: str) -> list: + """ + Convert Markdown to content.json blocks. + Supports: # headings, **bold**, bullet lists, > blockquotes (→ callout), + | tables |, plain paragraphs. + """ + blocks = [] + lines = text.splitlines() + i = 0 + + def flush_para(buf: list): + t = " ".join(buf).strip() + if t: + blocks.append({"type": "body", "text": _md_inline(t)}) + + para_buf = [] + + while i < len(lines): + line = lines[i] + stripped = line.strip() + + # Blank line — flush paragraph buffer + if not stripped: + flush_para(para_buf) + para_buf = [] + i += 1 + continue + + # ATX Headings: # ## ### + m = re.match(r'^(#{1,3})\s+(.*)', stripped) + if m: + flush_para(para_buf) + para_buf = [] + level = len(m.group(1)) + htype = {1: "h1", 2: "h2", 3: "h3"}.get(level, "h3") + blocks.append({"type": htype, "text": _md_inline(m.group(2))}) + i += 1 + continue + + # Display math block: $$expr$$ on one line, or opening $$ ... closing $$ + if stripped.startswith("$$"): + flush_para(para_buf) + para_buf = [] + inline_expr = stripped[2:].rstrip("$").strip() + if inline_expr: + # Single-line: $$E = mc^2$$ + blocks.append({"type": "math", "text": inline_expr}) + i += 1 + else: + # Multi-line: opening $$ alone, then expression lines, then closing $$ + math_lines = [] + i += 1 + while i < len(lines) and lines[i].strip() != "$$": + math_lines.append(lines[i]) + i += 1 + if i < len(lines): + i += 1 # skip closing $$ + blocks.append({"type": "math", "text": "\n".join(math_lines).strip()}) + continue + + # Fenced code block: ``` or ~~~ + if stripped.startswith("```") or stripped.startswith("~~~"): + flush_para(para_buf) + para_buf = [] + fence = stripped[:3] + code_lines = [] + i += 1 + while i < len(lines) and not lines[i].strip().startswith(fence): + code_lines.append(lines[i]) + i += 1 + if i < len(lines): + i += 1 # skip closing fence + blocks.append({"type": "code", "text": "\n".join(code_lines)}) + continue + + # Blockquote → callout + if stripped.startswith(">"): + flush_para(para_buf) + para_buf = [] + qt = re.sub(r'^>\s*', '', stripped) + blocks.append({"type": "callout", "text": _md_inline(qt)}) + i += 1 + continue + + # Unordered bullet: -, *, + + if re.match(r'^[-*+]\s+', stripped): + flush_para(para_buf) + para_buf = [] + text_part = re.sub(r'^[-*+]\s+', '', stripped) + blocks.append({"type": "bullet", "text": _md_inline(text_part)}) + i += 1 + continue + + # Ordered list: 1. 2. etc. → numbered (preserves counter in render_body) + if re.match(r'^\d+\.\s+', stripped): + flush_para(para_buf) + para_buf = [] + text_part = re.sub(r'^\d+\.\s+', '', stripped) + blocks.append({"type": "numbered", "text": _md_inline(text_part)}) + i += 1 + continue + + # Table: | col | col | + if stripped.startswith("|"): + flush_para(para_buf) + para_buf = [] + table_lines = [] + while i < len(lines) and lines[i].strip().startswith("|"): + table_lines.append(lines[i].strip()) + i += 1 + # Remove separator rows (|---|---|) + data_rows = [r for r in table_lines if not re.match(r'^\|[-:| ]+\|$', r)] + parsed = [] + for row in data_rows: + cells = [c.strip() for c in row.strip("|").split("|")] + parsed.append(cells) + if len(parsed) >= 2: + blocks.append({ + "type": "table", + "headers": parsed[0], + "rows": parsed[1:], + }) + elif len(parsed) == 1: + # Single row — treat as paragraph + blocks.append({"type": "body", "text": " | ".join(parsed[0])}) + continue + + # Horizontal rule → spacer + if re.match(r'^[-*_]{3,}$', stripped): + flush_para(para_buf) + para_buf = [] + blocks.append({"type": "spacer", "pt": 16}) + i += 1 + continue + + # Plain text → accumulate into paragraph + para_buf.append(stripped) + i += 1 + + flush_para(para_buf) + return blocks + + +def _md_inline(text: str) -> str: + """Convert inline Markdown to ReportLab XML markup.""" + # Bold: **text** or __text__ + text = re.sub(r'\*\*(.+?)\*\*', r'\1', text) + text = re.sub(r'__(.+?)__', r'\1', text) + # Italic: *text* or _text_ + text = re.sub(r'\*(.+?)\*', r'\1', text) + text = re.sub(r'_(.+?)_', r'\1', text) + # Inline code: `code` + text = re.sub(r'`(.+?)`', r'\1', text) + # Strip markdown links, keep text + text = re.sub(r'\[(.+?)\]\(.+?\)', r'\1', text) + return text + + +# ── PDF text extractor ───────────────────────────────────────────────────────── +def parse_pdf(pdf_path: str) -> list: + """ + Extract text from an existing PDF and convert to content.json blocks. + Best-effort: detects headings by font size heuristics if available, + otherwise falls back to paragraph splitting. + """ + from pypdf import PdfReader + + reader = PdfReader(pdf_path) + all_text = [] + + for page in reader.pages: + text = page.extract_text() + if text: + all_text.append(text.strip()) + + full_text = "\n\n".join(all_text) + + # Treat extracted PDF text as plain text / light markdown + # (most PDFs lose formatting — we do our best) + return parse_plain(full_text) + + +def parse_plain(text: str) -> list: + """ + Heuristic plain-text parser. + Short ALL-CAPS or title-case lines → headings. + Everything else → paragraphs. + """ + blocks = [] + paragraphs = re.split(r'\n{2,}', text.strip()) + + for para in paragraphs: + para = para.strip() + if not para: + continue + + lines = para.splitlines() + + # Single short line that looks like a heading + if len(lines) == 1 and len(para) < 80: + if para.isupper() or re.match(r'^[A-Z][^.!?]*$', para): + blocks.append({"type": "h1", "text": para.title()}) + continue + + # Bullet lists + if lines[0].startswith(("- ", "• ", "* ")): + for line in lines: + text_part = re.sub(r'^[-•*]\s+', '', line.strip()) + if text_part: + blocks.append({"type": "bullet", "text": text_part}) + continue + + # Regular paragraph + blocks.append({"type": "body", "text": " ".join(lines)}) + + return blocks + + +# ── Pass-through validator ───────────────────────────────────────────────────── +VALID_TYPES = {"h1","h2","h3","body","bullet","numbered","callout","table", + "image","code","math","divider","caption","pagebreak","spacer"} + +def validate_content_json(data: list) -> tuple[list, list]: + """Return (valid_blocks, warnings).""" + valid, warnings = [], [] + for i, block in enumerate(data): + if not isinstance(block, dict): + warnings.append(f"Block {i}: not a dict, skipped") + continue + btype = block.get("type") + if btype not in VALID_TYPES: + warnings.append(f"Block {i}: unknown type '{btype}', kept as-is") + valid.append(block) + return valid, warnings + + +# ── Dispatcher ───────────────────────────────────────────────────────────────── +def parse_file(input_path: str) -> tuple[list, list]: + """Return (blocks, warnings).""" + ext = Path(input_path).suffix.lower() + + if ext in (".md", ".txt", ".markdown"): + with open(input_path, encoding="utf-8", errors="replace") as f: + text = f.read() + blocks = parse_markdown(text) + return blocks, [] + + if ext == ".pdf": + blocks = parse_pdf(input_path) + return blocks, ["PDF text extraction is best-effort — review content.json before rendering"] + + if ext == ".json": + with open(input_path) as f: + data = json.load(f) + if isinstance(data, list): + return validate_content_json(data) + # Maybe it's a meta-wrapper {"content": [...]} + if isinstance(data, dict) and "content" in data: + return validate_content_json(data["content"]) + return [], [f"JSON file does not contain a list of content blocks"] + + return [], [f"Unsupported file type: {ext}. Supported: .md .txt .pdf .json"] + + +# ── CLI ──────────────────────────────────────────────────────────────────────── +def main(): + parser = argparse.ArgumentParser(description="Parse a document into content.json") + parser.add_argument("--input", required=True, help="Input file (.md, .txt, .pdf, .json)") + parser.add_argument("--out", default="content.json", help="Output content.json path") + args = parser.parse_args() + + if not os.path.exists(args.input): + print(json.dumps({"status": "error", "error": f"File not found: {args.input}"}), + file=sys.stderr) + sys.exit(1) + + try: + blocks, warnings = parse_file(args.input) + except Exception as e: + import traceback + print(json.dumps({"status": "error", "error": str(e), + "trace": traceback.format_exc()}), file=sys.stderr) + sys.exit(3) + + if not blocks: + print(json.dumps({ + "status": "error", + "error": "No content blocks extracted", + "warnings": warnings, + }), file=sys.stderr) + sys.exit(3) + + with open(args.out, "w", encoding="utf-8") as f: + json.dump(blocks, f, indent=2, ensure_ascii=False) + + result = { + "status": "ok", + "out": args.out, + "block_count": len(blocks), + "warnings": warnings, + } + print(json.dumps(result, indent=2)) + + print(f"\n── Parsed {args.input} ─────────────────────────────────────", + file=sys.stderr) + print(f" Blocks : {len(blocks)}", file=sys.stderr) + + type_counts: dict = {} + for b in blocks: + type_counts[b.get("type","?")] = type_counts.get(b.get("type","?"), 0) + 1 + for t, n in sorted(type_counts.items()): + print(f" {t:12} × {n}", file=sys.stderr) + + if warnings: + print(f" Warnings:", file=sys.stderr) + for w in warnings: + print(f" ⚠ {w}", file=sys.stderr) + print(f"\n Next: bash make.sh run --content {args.out} --title '...' --type ...", + file=sys.stderr) + print("", file=sys.stderr) + + +if __name__ == "__main__": + main() diff --git a/backend/app/skills_builtin/minimax-pdf/scripts/render_body.py b/backend/app/skills_builtin/minimax-pdf/scripts/render_body.py new file mode 100644 index 0000000..ef81de8 --- /dev/null +++ b/backend/app/skills_builtin/minimax-pdf/scripts/render_body.py @@ -0,0 +1,1052 @@ +#!/usr/bin/env python3 +""" +render_body.py — Build the inner-page PDF from tokens.json + content.json. + +Usage: + python3 render_body.py --tokens tokens.json --content content.json --out body.pdf + +Block types: + h1 h2 h3 Headings (h1 adds a full-width accent rule below) + body Justified prose paragraph + bullet Bullet list item (• prefix) + numbered Auto-numbered list item (resets when interrupted) + callout Highlighted insight box with left accent bar + table Data table with accent header + alternating rows + image Inline image from file path + figure Image with auto-numbered "Figure N:" caption + code Monospace code block with accent left border + math Display math formula via matplotlib mathtext + chart Bar / line / pie chart rendered via matplotlib + flowchart Process diagram rendered via matplotlib + bibliography Numbered reference list + divider Full-width accent rule + caption Small muted text (e.g., under a figure) + pagebreak Force a new page + spacer Vertical whitespace (pt field, default 12) + +Exit codes: 0 success, 1 bad args/missing file, 2 missing dep, 3 render error +""" + +import argparse +import io +import json +import os +import sys +import importlib.util + + +# ── Dependency bootstrap ─────────────────────────────────────────────────────── +def ensure_deps(): + missing = [p for p in ("reportlab", "pypdf") + if importlib.util.find_spec(p) is None] + if missing: + import subprocess + subprocess.check_call( + [sys.executable, "-m", "pip", "install", + "--break-system-packages", "-q"] + missing + ) + + +ensure_deps() + +from reportlab.platypus import ( + BaseDocTemplate, PageTemplate, Frame, + Paragraph, Spacer, Table, TableStyle, + HRFlowable, PageBreak, Flowable, KeepTogether, + Preformatted, Image as RLImage, +) +from reportlab.lib.pagesizes import A4 +from reportlab.lib.styles import ParagraphStyle +from reportlab.lib.colors import HexColor +from reportlab.lib.enums import TA_JUSTIFY, TA_CENTER +from reportlab.pdfbase import pdfmetrics +from reportlab.pdfbase.ttfonts import TTFont + + +# ── Font registration ────────────────────────────────────────────────────────── +def register_fonts(tokens: dict): + """Register TTF fonts from token font_paths if present.""" + for name, fpath in tokens.get("font_paths", {}).items(): + if os.path.exists(fpath): + try: + pdfmetrics.registerFont(TTFont(name, fpath)) + except Exception: + pass + + +# ══════════════════════════════════════════════════════════════════════════════ +# Custom Flowables +# ══════════════════════════════════════════════════════════════════════════════ + +class CalloutBox(Flowable): + """Highlighted insight box: coloured background + 4px left accent bar.""" + + def __init__(self, text: str, style, accent: str, bg: str): + super().__init__() + self._para = Paragraph(text, style) + self._accent = HexColor(accent) + self._bg = HexColor(bg) + + def wrap(self, aw, ah): + self._w = aw + _, ph = self._para.wrap(aw - 36, ah) + self._h = ph + 22 + return aw, self._h + + def draw(self): + c = self.canv + c.setFillColor(self._bg) + c.roundRect(0, 0, self._w, self._h, 5, fill=1, stroke=0) + c.setFillColor(self._accent) + c.rect(0, 0, 4, self._h, fill=1, stroke=0) + self._para.drawOn(c, 18, 11) + + +class BibliographyItem(Flowable): + """Single hanging-indent bibliography entry rendered as [N] text.""" + + LABEL_W = 28 + + def __init__(self, ref_id: str, text: str, style, dark: str): + super().__init__() + self._id = ref_id + self._text = text + self._style = style + self._dark = HexColor(dark) + + def wrap(self, aw, ah): + self._w = aw + self._para = Paragraph(self._text, self._style) + _, ph = self._para.wrap(aw - self.LABEL_W, ah) + self._h = ph + 4 + return aw, self._h + + def draw(self): + c = self.canv + c.setFillColor(self._dark) + c.setFont("Helvetica-Bold", 8.5) + c.drawString(0, self._h - 12, f"[{self._id}]") + self._para.drawOn(c, self.LABEL_W, 2) + + +# ══════════════════════════════════════════════════════════════════════════════ +# Page template (header + footer) +# ══════════════════════════════════════════════════════════════════════════════ + +class BeautifulDoc(BaseDocTemplate): + def __init__(self, path: str, tokens: dict, **kw): + self._t = tokens + super().__init__(path, **kw) + fr = Frame( + self.leftMargin, self.bottomMargin, + self.width, self.height, id="body", + ) + tmpl = PageTemplate(id="main", frames=fr, onPage=self._decorate) + self.addPageTemplates([tmpl]) + + def _decorate(self, canv, doc): + t = self._t + lm = doc.leftMargin + rm = doc.rightMargin + pw = doc.pagesize[0] + ph = doc.pagesize[1] + top = ph - doc.topMargin + + canv.saveState() + + # Header accent rule + canv.setStrokeColor(HexColor(t["accent"])) + canv.setLineWidth(1.5) + canv.line(lm, top + 12, pw - rm, top + 12) + + # Header: title (left) + date (right) + canv.setFillColor(HexColor(t["muted"])) + canv.setFont(t["font_body_rl"], t["size_meta"]) + canv.drawString(lm, top + 16, t["title"].upper()) + canv.drawRightString(pw - rm, top + 16, t.get("date", "")) + + # Footer rule + canv.setStrokeColor(HexColor("#DDDDDD")) + canv.setLineWidth(0.5) + canv.line(lm, doc.bottomMargin - 12, pw - rm, doc.bottomMargin - 12) + + # Footer: author (left) + page number (right) + canv.setFillColor(HexColor(t["muted"])) + canv.setFont(t["font_body_rl"], t["size_meta"]) + canv.drawString(lm, doc.bottomMargin - 22, t.get("author", "")) + canv.drawRightString(pw - rm, doc.bottomMargin - 22, str(doc.page)) + + canv.restoreState() + + +# ══════════════════════════════════════════════════════════════════════════════ +# Style factory +# ══════════════════════════════════════════════════════════════════════════════ + +def make_styles(t: dict) -> dict: + hf = t["font_display_rl"] + bf = t["font_body_rl"] + bfb = t["font_body_b_rl"] + dk = t["body_text"] + d = t["dark"] + mu = t["muted"] + + return { + "h1": ParagraphStyle("H1", + fontName=hf, fontSize=t["size_h1"], + leading=t["size_h1"] * 1.3, + textColor=HexColor(d), + spaceBefore=t["section_gap"], spaceAfter=4, + ), + "h2": ParagraphStyle("H2", + fontName=hf, fontSize=t["size_h2"], + leading=t["size_h2"] * 1.4, + textColor=HexColor(d), + spaceBefore=18, spaceAfter=5, + ), + "h3": ParagraphStyle("H3", + fontName=bfb, fontSize=t["size_h3"], + leading=t["size_h3"] * 1.5, + textColor=HexColor(d), + spaceBefore=12, spaceAfter=3, + ), + "body": ParagraphStyle("Body", + fontName=bf, fontSize=t["size_body"], + leading=t["line_gap"], + textColor=HexColor(dk), + spaceAfter=t["para_gap"], alignment=TA_JUSTIFY, + ), + "bullet": ParagraphStyle("Bullet", + fontName=bf, fontSize=t["size_body"], + leading=t["line_gap"] - 1, + textColor=HexColor(dk), + spaceAfter=4, leftIndent=14, + ), + "numbered": ParagraphStyle("Numbered", + fontName=bf, fontSize=t["size_body"], + leading=t["line_gap"] - 1, + textColor=HexColor(dk), + spaceAfter=4, leftIndent=22, firstLineIndent=-22, + ), + "callout": ParagraphStyle("Callout", + fontName=bfb, fontSize=t["size_body"] + 0.5, leading=16, + textColor=HexColor(d), + ), + "caption": ParagraphStyle("Caption", + fontName=bf, fontSize=t["size_caption"], leading=13, + textColor=HexColor(mu), spaceAfter=6, + alignment=TA_CENTER, + ), + "table_header": ParagraphStyle("TblH", + fontName=bfb, fontSize=9.5, leading=13, + textColor=HexColor("#FFFFFF"), + ), + "table_cell": ParagraphStyle("TblC", + fontName=bf, fontSize=9.5, leading=13, + textColor=HexColor(dk), + ), + "code": ParagraphStyle("Code", + fontName="Courier", fontSize=8.5, leading=12.5, + textColor=HexColor(dk), + ), + "code_lang": ParagraphStyle("CodeLang", + fontName="Courier", fontSize=7, leading=10, + textColor=HexColor(mu), + ), + "bib": ParagraphStyle("Bib", + fontName=bf, fontSize=9, leading=14, + textColor=HexColor(dk), + ), + "bib_title": ParagraphStyle("BibTitle", + fontName=hf, fontSize=t["size_h2"], + leading=t["size_h2"] * 1.4, + textColor=HexColor(d), + spaceBefore=t["section_gap"], spaceAfter=8, + ), + "math_fallback": ParagraphStyle("MathFb", + fontName="Courier", fontSize=9, leading=13, + textColor=HexColor(dk), + ), + "eq_label": ParagraphStyle("EqLabel", + fontName="Helvetica", fontSize=9, leading=12, + textColor=HexColor(mu), + ), + } + + +# ══════════════════════════════════════════════════════════════════════════════ +# Shared helpers +# ══════════════════════════════════════════════════════════════════════════════ + +def _divider(accent: str) -> HRFlowable: + return HRFlowable( + width="100%", thickness=1.2, + color=HexColor(accent), + spaceBefore=14, spaceAfter=14, + ) + + +def _image_from_bytes(png_bytes: bytes, usable_w: float, + max_frac: float = 0.88) -> RLImage: + """Create a scaled RLImage from PNG bytes, bounded to max_frac of usable_w.""" + img = RLImage(io.BytesIO(png_bytes)) + max_w = usable_w * max_frac + if img.drawWidth > max_w: + scale = max_w / img.drawWidth + img.drawWidth = max_w + img.drawHeight = img.drawHeight * scale + return img + + +# ══════════════════════════════════════════════════════════════════════════════ +# PNG renderers (matplotlib) +# ══════════════════════════════════════════════════════════════════════════════ + +def _render_math_png(expr: str, dpi: int = 180) -> bytes | None: + """ + Render a LaTeX math expression via matplotlib mathtext. + No LaTeX binary required — uses matplotlib's built-in math parser. + Supports: fractions (\\frac), integrals (\\int), sums (\\sum), + Greek letters, sub/superscripts, etc. + """ + try: + import matplotlib + matplotlib.use("Agg") + import matplotlib.pyplot as plt + + fig = plt.figure(figsize=(8, 1.2)) + fig.patch.set_facecolor("white") + ax = fig.add_axes([0, 0, 1, 1]) + ax.set_axis_off() + ax.set_facecolor("white") + ax.text(0.5, 0.5, f"${expr}$", + fontsize=16, ha="center", va="center", + transform=ax.transAxes) + buf = io.BytesIO() + fig.savefig(buf, format="png", dpi=dpi, bbox_inches="tight", + facecolor="white", pad_inches=0.1) + plt.close(fig) + buf.seek(0) + return buf.read() + except Exception: + return None + + +def _render_chart_png(item: dict, accent: str, dpi: int = 150) -> bytes | None: + """ + Render bar / line / pie chart to PNG using matplotlib. + + Required fields: + chart_type "bar" | "line" | "pie" (default "bar") + labels list of category strings + datasets list of {label?, values: list[number]} + + Optional fields: + title chart title + x_label X-axis label + y_label Y-axis label + """ + try: + import matplotlib + matplotlib.use("Agg") + import matplotlib.pyplot as plt + import matplotlib.colors as mcolors + import colorsys + import numpy as np + + chart_type = item.get("chart_type", "bar") + title_text = item.get("title", "") + labels = item.get("labels", []) + datasets = item.get("datasets", []) + + # Derive a consistent palette from the document accent color + r, g, b = mcolors.to_rgb(accent) + h, s, v = colorsys.rgb_to_hsv(r, g, b) + palette = [ + colorsys.hsv_to_rgb( + (h + i * 0.13) % 1.0, + max(0.35, s - i * 0.08), + min(0.92, v + i * 0.04), + ) + for i in range(max(len(datasets), 1)) + ] + + fig, ax = plt.subplots(figsize=(7, 3.6), dpi=dpi) + fig.patch.set_facecolor("white") + ax.set_facecolor("white") + + if chart_type == "bar": + x = np.arange(len(labels)) + n = max(len(datasets), 1) + width = 0.68 / n + for i, ds in enumerate(datasets): + offset = (i - (n - 1) / 2) * width + ax.bar(x + offset, ds.get("values", []), width * 0.88, + label=ds.get("label", f"Series {i+1}"), + color=palette[i % len(palette)], edgecolor="none") + ax.set_xticks(x) + ax.set_xticklabels(labels, fontsize=8.5) + ax.yaxis.grid(True, alpha=0.25, color="#CCCCCC", linewidth=0.7) + ax.set_axisbelow(True) + if item.get("x_label"): + ax.set_xlabel(item["x_label"], fontsize=8.5) + if item.get("y_label"): + ax.set_ylabel(item["y_label"], fontsize=8.5) + + elif chart_type == "line": + x = np.arange(len(labels)) + for i, ds in enumerate(datasets): + ax.plot(x, ds.get("values", []), marker="o", markersize=3.5, + label=ds.get("label", f"Series {i+1}"), + color=palette[i % len(palette)], linewidth=1.8) + ax.set_xticks(x) + ax.set_xticklabels(labels, fontsize=8.5) + ax.yaxis.grid(True, alpha=0.25, color="#CCCCCC", linewidth=0.7) + ax.set_axisbelow(True) + if item.get("x_label"): + ax.set_xlabel(item["x_label"], fontsize=8.5) + if item.get("y_label"): + ax.set_ylabel(item["y_label"], fontsize=8.5) + + elif chart_type == "pie": + vals = datasets[0].get("values", []) if datasets else [] + colors = [ + colorsys.hsv_to_rgb( + (h + i * 0.11) % 1.0, + max(0.30, s - i * 0.06), + min(0.92, v + i * 0.03), + ) + for i in range(len(vals)) + ] + ax.pie(vals, labels=labels, colors=colors, + autopct="%1.1f%%", pctdistance=0.82, + wedgeprops=dict(edgecolor="white", linewidth=1.4), + textprops=dict(fontsize=8.5)) + + # Shared styling + for spine in ax.spines.values(): + spine.set_linewidth(0.5) + spine.set_color("#CCCCCC") + ax.tick_params(axis="both", length=0, labelsize=8.5) + if title_text: + ax.set_title(title_text, fontsize=10, pad=8, + color="#333333", fontweight="bold") + if len(datasets) > 1 and chart_type != "pie": + ax.legend(frameon=False, fontsize=8, loc="upper right") + + plt.tight_layout(pad=0.4) + buf = io.BytesIO() + fig.savefig(buf, format="png", dpi=dpi, bbox_inches="tight", + facecolor="white", pad_inches=0.06) + plt.close(fig) + buf.seek(0) + return buf.read() + except Exception: + return None + + +def _render_flowchart_png(item: dict, accent: str, dark: str, + muted: str, dpi: int = 130) -> bytes | None: + """ + Render a top-to-bottom flowchart using matplotlib patches and arrows. + + Node schema: {id, label, shape?} + shape: "rect" (default) | "diamond" | "oval" | "parallelogram" + + Edge schema: {from, to, label?} + Forward edges (to a later node) draw straight arrows. + Back edges (to an earlier node) draw a curved arc to the right. + """ + try: + import matplotlib + matplotlib.use("Agg") + import matplotlib.pyplot as plt + import matplotlib.patches as mpatch + from matplotlib.patches import FancyBboxPatch + import matplotlib.colors as mcolors + + nodes_list = item.get("nodes", []) + edges = item.get("edges", []) + if not nodes_list: + return None + + nodes = {n["id"]: n for n in nodes_list} + order = {n["id"]: i for i, n in enumerate(nodes_list)} + + n_nodes = len(nodes_list) + BOX_W = 4.2 + BOX_H = 0.58 + STEP_Y = 1.25 + CX = 5.0 + + fig_h = max(3.5, n_nodes * STEP_Y + 0.8) + fig, ax = plt.subplots(figsize=(6, fig_h), dpi=dpi) + fig.patch.set_facecolor("white") + ax.set_facecolor("white") + ax.set_xlim(0, 10) + ax.set_ylim(-0.6, n_nodes * STEP_Y + 0.2) + ax.invert_yaxis() + ax.axis("off") + + acc_rgb = mcolors.to_rgb(accent) + dark_rgb = mcolors.to_rgb(dark) + muted_rgb = mcolors.to_rgb(muted) + + # Node positions (cx, cy) — preserves input order + pos = {nid: (CX, i * STEP_Y) for nid, i in order.items()} + + # ── Draw edges (behind nodes) ────────────────────────────────────────── + for edge in edges: + src, dst = edge.get("from"), edge.get("to") + if src not in pos or dst not in pos: + continue + x1, y1 = pos[src] + x2, y2 = pos[dst] + lbl = edge.get("label", "") + + src_shape = nodes.get(src, {}).get("shape", "rect") + dst_shape = nodes.get(dst, {}).get("shape", "rect") + dy_src = BOX_H * (0.80 if src_shape == "diamond" else 0.50) + dy_dst = BOX_H * (0.80 if dst_shape == "diamond" else 0.50) + + y_start = y1 + dy_src + y_end = y2 - dy_dst + + # Forward edge: straight; back-edge: curved arc + conn = "arc3,rad=0.0" if y_end > y_start + 0.01 else "arc3,rad=0.42" + + ax.annotate("", + xy=(x2, y_end), xytext=(x1, y_start), + arrowprops=dict( + arrowstyle="-|>", color=muted_rgb, + lw=1.0, mutation_scale=10, + connectionstyle=conn, + ), + ) + if lbl: + mid_x = (x1 + x2) / 2 + 0.28 + mid_y = (y_start + y_end) / 2 + ax.text(mid_x, mid_y, lbl, fontsize=7.5, + color=muted_rgb, ha="left", va="center") + + # ── Draw nodes (in front of edges) ──────────────────────────────────── + for nid, (cx, cy) in pos.items(): + node = nodes[nid] + shape = node.get("shape", "rect") + label = node.get("label", nid) + left = cx - BOX_W / 2 + bot = cy - BOX_H / 2 + + if shape in ("oval", "terminal"): + el = mpatch.Ellipse( + (cx, cy), BOX_W * 0.78, BOX_H * 1.15, + facecolor=acc_rgb, edgecolor=acc_rgb, linewidth=0, + ) + ax.add_patch(el) + ax.text(cx, cy, label, ha="center", va="center", + fontsize=8.5, fontweight="bold", color="white") + + elif shape == "diamond": + d = BOX_W * 0.44 + diamond = plt.Polygon( + [(cx, cy - d * 0.72), (cx + d, cy), + (cx, cy + d * 0.72), (cx - d, cy)], + facecolor="#FFFCF0", + edgecolor=accent, linewidth=1.2, + ) + ax.add_patch(diamond) + ax.text(cx, cy, label, ha="center", va="center", + fontsize=8, color=dark_rgb) + + elif shape == "parallelogram": + skew = 0.30 + para = plt.Polygon( + [(left + skew, bot), (left + BOX_W + skew, bot), + (left + BOX_W, bot + BOX_H), (left, bot + BOX_H)], + facecolor="white", + edgecolor=accent, linewidth=1.2, + ) + ax.add_patch(para) + ax.text(cx, cy, label, ha="center", va="center", + fontsize=8.5, color=dark_rgb) + + else: # rect (default) + rect = FancyBboxPatch( + (left, bot), BOX_W, BOX_H, + boxstyle="round,pad=0.04", + facecolor="white", + edgecolor=accent, linewidth=1.2, + ) + ax.add_patch(rect) + ax.text(cx, cy, label, ha="center", va="center", + fontsize=8.5, color=dark_rgb) + + plt.tight_layout(pad=0.2) + buf = io.BytesIO() + fig.savefig(buf, format="png", dpi=dpi, bbox_inches="tight", + facecolor="white", pad_inches=0.08) + plt.close(fig) + buf.seek(0) + return buf.read() + except Exception: + return None + + +# ══════════════════════════════════════════════════════════════════════════════ +# Block renderers +# +# All functions share the same signature: +# _add_XXX(story: list, item: dict, ctx: dict) +# +# ctx keys: +# tokens dict design tokens from palette.py +# styles dict ParagraphStyle objects from make_styles() +# usable_w float usable page width in points +# acc str accent hex color +# acc_lt str light accent hex color +# mu str muted hex color +# dark str dark hex color +# figure_n int auto-incrementing figure counter (mutable) +# numbered_n int auto-incrementing list counter (mutable) +# ══════════════════════════════════════════════════════════════════════════════ + +def _add_heading(story: list, item: dict, ctx: dict, level: int): + key = f"h{level}" + para = Paragraph(item["text"], ctx["styles"][key]) + if level == 1: + story.append(KeepTogether([para, _divider(ctx["acc"])])) + else: + story.append(para) + + +def _add_body(story: list, item: dict, ctx: dict): + story.append(Paragraph(item["text"], ctx["styles"]["body"])) + + +def _add_bullet(story: list, item: dict, ctx: dict): + story.append(Paragraph( + f"\u2022\u2002{item['text']}", ctx["styles"]["bullet"] + )) + + +def _add_numbered(story: list, item: dict, ctx: dict): + ctx["numbered_n"] += 1 + story.append(Paragraph( + f"{ctx['numbered_n']}.\u2002{item['text']}", + ctx["styles"]["numbered"], + )) + + +def _add_callout(story: list, item: dict, ctx: dict): + story.append(Spacer(1, 8)) + story.append(CalloutBox( + item["text"], ctx["styles"]["callout"], ctx["acc"], ctx["acc_lt"] + )) + story.append(Spacer(1, 8)) + + +def _add_table(story: list, item: dict, ctx: dict): + t = ctx["tokens"] + styles = ctx["styles"] + usable_w = ctx["usable_w"] + acc = ctx["acc"] + acc_lt = ctx["acc_lt"] + + headers = [Paragraph(h, styles["table_header"]) for h in item["headers"]] + rows = [ + [Paragraph(str(c), styles["table_cell"]) for c in row] + for row in item.get("rows", []) + ] + n_cols = len(item["headers"]) + + # Optional col_widths as fractions summing to 1.0 + if "col_widths" in item and len(item["col_widths"]) == n_cols: + col_w = [usable_w * f for f in item["col_widths"]] + else: + col_w = [usable_w / n_cols] * n_cols + + tbl = Table([headers] + rows, colWidths=col_w) + tbl.setStyle(TableStyle([ + ("BACKGROUND", (0, 0), (-1, 0), HexColor(acc)), + ("TEXTCOLOR", (0, 0), (-1, 0), HexColor("#FFFFFF")), + ("FONTNAME", (0, 0), (-1, 0), t["font_body_b_rl"]), + ("FONTSIZE", (0, 0), (-1, 0), 9.5), + ("TOPPADDING", (0, 0), (-1, 0), 7), + ("BOTTOMPADDING", (0, 0), (-1, 0), 7), + ("ROWBACKGROUNDS", (0, 1), (-1, -1), + [HexColor("#FFFFFF"), HexColor(acc_lt)]), + ("FONTNAME", (0, 1), (-1, -1), t["font_body_rl"]), + ("FONTSIZE", (0, 1), (-1, -1), 9.5), + ("TOPPADDING", (0, 1), (-1, -1), 6), + ("BOTTOMPADDING", (0, 1), (-1, -1), 6), + ("LEFTPADDING", (0, 0), (-1, -1), 10), + ("RIGHTPADDING", (0, 0), (-1, -1), 10), + ("BOX", (0, 0), (-1, -1), 0.5, HexColor("#CCCCCC")), + ("LINEBELOW", (0, 0), (-1, 0), 1.2, HexColor(acc)), + ("TEXTCOLOR", (0, 1), (-1, -1), HexColor(t["body_text"])), + ("VALIGN", (0, 0), (-1, -1), "MIDDLE"), + ])) + story.append(tbl) + if item.get("caption"): + story.append(Spacer(1, 4)) + story.append(Paragraph(item["caption"], styles["caption"])) + story.append(Spacer(1, 12)) + + +def _add_image(story: list, item: dict, ctx: dict): + path = str(item.get("path", item.get("src", ""))) + if not os.path.exists(path): + story.append(Paragraph( + f"[Image not found: {path}]", ctx["styles"]["caption"] + )) + return + try: + img = RLImage(path) + uw = ctx["usable_w"] + if img.drawWidth > uw: + scale = uw / img.drawWidth + img.drawWidth = uw + img.drawHeight = img.drawHeight * scale + story.append(img) + except Exception as e: + story.append(Paragraph(f"[Image error: {e}]", ctx["styles"]["caption"])) + return + if item.get("caption"): + story.append(Spacer(1, 4)) + story.append(Paragraph(item["caption"], ctx["styles"]["caption"])) + story.append(Spacer(1, 8)) + + +def _add_figure(story: list, item: dict, ctx: dict): + """Like image but auto-numbers the caption as 'Figure N: ...'.""" + ctx["figure_n"] += 1 + raw_cap = item.get("caption", "") + caption = f"Figure {ctx['figure_n']}: {raw_cap}" if raw_cap \ + else f"Figure {ctx['figure_n']}" + _add_image(story, {**item, "caption": caption}, ctx) + + +def _add_code(story: list, item: dict, ctx: dict): + acc = ctx["acc"] + acc_lt = ctx["acc_lt"] + mu = ctx["mu"] + uw = ctx["usable_w"] + lang = item.get("language", "") + + pre = Preformatted(item.get("text", ""), ctx["styles"]["code"]) + tbl = Table([[pre]], colWidths=[uw]) + tbl.setStyle(TableStyle([ + ("BACKGROUND", (0, 0), (-1, -1), HexColor(acc_lt)), + ("LINEBEFORE", (0, 0), ( 0, -1), 3, HexColor(acc)), + ("BOX", (0, 0), (-1, -1), 0.5, HexColor(mu)), + ("LEFTPADDING", (0, 0), (-1, -1), 14), + ("RIGHTPADDING", (0, 0), (-1, -1), 10), + ("TOPPADDING", (0, 0), (-1, -1), 8), + ("BOTTOMPADDING", (0, 0), (-1, -1), 8), + ])) + story.append(Spacer(1, 6)) + if lang: + story.append(Paragraph(lang.upper(), ctx["styles"]["code_lang"])) + story.append(tbl) + story.append(Spacer(1, 6)) + + +def _add_math(story: list, item: dict, ctx: dict): + """ + Display math block. + + Fields: + text LaTeX math expression (without enclosing $) + label optional equation label, e.g. "(1)" — displayed right-aligned + caption optional caption below the formula + + Example: + {"type": "math", "text": "E = mc^2", "label": "(1)"} + {"type": "math", "text": "\\\\int_0^\\\\infty e^{-x^2}\\\\,dx = \\\\frac{\\\\sqrt{\\\\pi}}{2}"} + """ + acc = ctx["acc"] + acc_lt = ctx["acc_lt"] + uw = ctx["usable_w"] + expr = item.get("text", "").strip() + label = item.get("label", "").strip() + + png = _render_math_png(expr) + + if png is None: + # Graceful text fallback if matplotlib unavailable + story.append(Spacer(1, 6)) + pre = Preformatted(f" {expr}", ctx["styles"]["math_fallback"]) + tbl = Table([[pre]], colWidths=[uw]) + tbl.setStyle(TableStyle([ + ("BACKGROUND", (0, 0), (-1, -1), HexColor(acc_lt)), + ("LEFTPADDING", (0, 0), (-1, -1), 14), + ("RIGHTPADDING", (0, 0), (-1, -1), 14), + ("TOPPADDING", (0, 0), (-1, -1), 8), + ("BOTTOMPADDING", (0, 0), (-1, -1), 8), + ])) + story.append(tbl) + story.append(Spacer(1, 6)) + return + + img = _image_from_bytes(png, uw, max_frac=0.72) + story.append(Spacer(1, 10)) + + if label: + label_w = 44 + formula_w = uw - label_w + lbl_para = Paragraph(label, ctx["styles"]["eq_label"]) + row_tbl = Table([[img, lbl_para]], colWidths=[formula_w, label_w]) + row_tbl.setStyle(TableStyle([ + ("ALIGN", (0, 0), (0, 0), "CENTER"), + ("ALIGN", (1, 0), (1, 0), "RIGHT"), + ("VALIGN", (0, 0), (-1, -1), "MIDDLE"), + ])) + story.append(row_tbl) + else: + row_tbl = Table([[img]], colWidths=[uw]) + row_tbl.setStyle(TableStyle([ + ("ALIGN", (0, 0), (-1, -1), "CENTER"), + ])) + story.append(row_tbl) + + if item.get("caption"): + story.append(Spacer(1, 4)) + story.append(Paragraph(item["caption"], ctx["styles"]["caption"])) + story.append(Spacer(1, 10)) + + +def _add_chart(story: list, item: dict, ctx: dict): + """ + Render a chart (bar / line / pie) via matplotlib. + + Fields: + chart_type "bar" | "line" | "pie" (default "bar") + title chart title + labels list of category strings + datasets list of {label?, values: list[number]} + x_label X-axis label (bar/line) + y_label Y-axis label (bar/line) + caption caption text below chart + figure bool (default true) — prefix caption with "Figure N:" + """ + uw = ctx["usable_w"] + png = _render_chart_png(item, ctx["acc"]) + + if png is None: + story.append(Paragraph( + "[Chart: install matplotlib to render — pip install matplotlib]", + ctx["styles"]["caption"], + )) + return + + img = _image_from_bytes(png, uw, max_frac=0.95) + story.append(Spacer(1, 8)) + row_tbl = Table([[img]], colWidths=[uw]) + row_tbl.setStyle(TableStyle([("ALIGN", (0, 0), (-1, -1), "CENTER")])) + story.append(row_tbl) + + raw_cap = item.get("caption", "") + use_fig = item.get("figure", True) + if raw_cap or use_fig: + ctx["figure_n"] += 1 + prefix = f"Figure {ctx['figure_n']}: " if use_fig else "" + story.append(Spacer(1, 4)) + story.append(Paragraph(prefix + raw_cap, ctx["styles"]["caption"])) + story.append(Spacer(1, 10)) + + +def _add_flowchart(story: list, item: dict, ctx: dict): + """ + Render a flowchart via matplotlib. + + Fields: + nodes list of {id, label, shape?} + shape: "rect" (default) | "diamond" | "oval" | "parallelogram" + edges list of {from, to, label?} + caption caption below the diagram + figure bool (default true) — prefix caption with "Figure N:" + """ + uw = ctx["usable_w"] + png = _render_flowchart_png(item, ctx["acc"], ctx["dark"], ctx["mu"]) + + if png is None: + story.append(Paragraph( + "[Flowchart: install matplotlib to render — pip install matplotlib]", + ctx["styles"]["caption"], + )) + return + + img = _image_from_bytes(png, uw, max_frac=0.78) + story.append(Spacer(1, 8)) + row_tbl = Table([[img]], colWidths=[uw]) + row_tbl.setStyle(TableStyle([("ALIGN", (0, 0), (-1, -1), "CENTER")])) + story.append(row_tbl) + + raw_cap = item.get("caption", "") + use_fig = item.get("figure", True) + if raw_cap or use_fig: + ctx["figure_n"] += 1 + prefix = f"Figure {ctx['figure_n']}: " if use_fig else "" + story.append(Spacer(1, 4)) + story.append(Paragraph(prefix + raw_cap, ctx["styles"]["caption"])) + story.append(Spacer(1, 10)) + + +def _add_bibliography(story: list, item: dict, ctx: dict): + """ + Numbered reference list with hanging indent. + + Fields: + title section heading (default "References"); set "" to suppress + items list of {id, text} + + Example: + {"type": "bibliography", + "items": [ + {"id": "1", "text": "Smith, J. (2023). Title. Journal, 10(2), 1–15."}, + {"id": "2", "text": "Doe, A. (2022). Another title. Publisher."} + ]} + """ + heading = item.get("title", "References") + if heading: + story.append(KeepTogether([ + Paragraph(heading, ctx["styles"]["bib_title"]), + _divider(ctx["acc"]), + ])) + + for ref in item.get("items", []): + story.append(Spacer(1, 4)) + story.append(BibliographyItem( + str(ref.get("id", "")), + ref.get("text", ""), + ctx["styles"]["bib"], + ctx["dark"], + )) + + +# ══════════════════════════════════════════════════════════════════════════════ +# Story builder +# ══════════════════════════════════════════════════════════════════════════════ + +# Block types that break a numbered list sequence +_RESETS_NUMBERED = frozenset({ + "h1", "h2", "h3", "body", "bullet", "callout", "table", + "image", "figure", "code", "math", "chart", "flowchart", + "bibliography", "divider", "caption", "pagebreak", "spacer", +}) + + +def build_story(content: list, tokens: dict, styles: dict) -> list: + usable_w = A4[0] - tokens["margin_left"] - tokens["margin_right"] + + ctx: dict = { + "tokens": tokens, + "styles": styles, + "usable_w": usable_w, + "acc": tokens["accent"], + "acc_lt": tokens["accent_lt"], + "mu": tokens["muted"], + "dark": tokens["dark"], + "figure_n": 0, + "numbered_n": 0, + } + + story: list = [] + + for item in content: + kind = item.get("type", "body") + + if kind in _RESETS_NUMBERED: + ctx["numbered_n"] = 0 + + if kind == "h1": _add_heading(story, item, ctx, 1) + elif kind == "h2": _add_heading(story, item, ctx, 2) + elif kind == "h3": _add_heading(story, item, ctx, 3) + elif kind == "body": _add_body(story, item, ctx) + elif kind == "bullet": _add_bullet(story, item, ctx) + elif kind == "numbered": _add_numbered(story, item, ctx) + elif kind == "callout": _add_callout(story, item, ctx) + elif kind == "table": _add_table(story, item, ctx) + elif kind == "image": _add_image(story, item, ctx) + elif kind == "figure": _add_figure(story, item, ctx) + elif kind == "code": _add_code(story, item, ctx) + elif kind == "math": _add_math(story, item, ctx) + elif kind == "chart": _add_chart(story, item, ctx) + elif kind == "flowchart": _add_flowchart(story, item, ctx) + elif kind == "bibliography": _add_bibliography(story, item, ctx) + elif kind == "divider": story.append(_divider(ctx["acc"])) + elif kind == "caption": + story.append(Paragraph(item["text"], styles["caption"])) + elif kind == "pagebreak": story.append(PageBreak()) + elif kind == "spacer": story.append(Spacer(1, item.get("pt", 12))) + + return story + + +# ══════════════════════════════════════════════════════════════════════════════ +# Main build +# ══════════════════════════════════════════════════════════════════════════════ + +def build(tokens: dict, content: list, out_path: str) -> dict: + register_fonts(tokens) + styles = make_styles(tokens) + + doc = BeautifulDoc( + out_path, tokens, + pagesize=A4, + leftMargin=tokens["margin_left"], + rightMargin=tokens["margin_right"], + topMargin=tokens["margin_top"], + bottomMargin=tokens["margin_bottom"], + ) + doc.build(build_story(content, tokens, styles)) + + size = os.path.getsize(out_path) + return {"status": "ok", "out": out_path, "size_kb": size // 1024} + + +# ══════════════════════════════════════════════════════════════════════════════ +# CLI +# ══════════════════════════════════════════════════════════════════════════════ + +def main(): + parser = argparse.ArgumentParser( + description="Render body PDF from tokens.json + content.json" + ) + parser.add_argument("--tokens", default="tokens.json") + parser.add_argument("--content", default="content.json") + parser.add_argument("--out", default="body.pdf") + args = parser.parse_args() + + for fpath in (args.tokens, args.content): + if not os.path.exists(fpath): + print( + json.dumps({"status": "error", + "error": f"File not found: {fpath}"}), + file=sys.stderr, + ) + sys.exit(1) + + with open(args.tokens, encoding="utf-8") as f: + tokens = json.load(f) + with open(args.content, encoding="utf-8") as f: + content = json.load(f) + + try: + result = build(tokens, content, args.out) + print(json.dumps(result)) + except Exception as e: + import traceback + print( + json.dumps({ + "status": "error", + "error": str(e), + "trace": traceback.format_exc(), + }), + file=sys.stderr, + ) + sys.exit(3) + + +if __name__ == "__main__": + main() diff --git a/backend/app/skills_builtin/minimax-pdf/scripts/render_cover.js b/backend/app/skills_builtin/minimax-pdf/scripts/render_cover.js new file mode 100644 index 0000000..8a29692 --- /dev/null +++ b/backend/app/skills_builtin/minimax-pdf/scripts/render_cover.js @@ -0,0 +1,111 @@ +#!/usr/bin/env node +/** + * render_cover.js — Render cover.html → cover.pdf via Playwright. + * + * Usage: + * node render_cover.js --input cover.html --out cover.pdf + * node render_cover.js --input cover.html --out cover.pdf --wait 1200 + * + * Exit codes: 0 success, 1 bad args, 2 dependency missing, 3 render error + */ + +const path = require("path"); +const fs = require("fs"); + +function usage() { + console.error("Usage: node render_cover.js --input --out [--wait ]"); + process.exit(1); +} + +// ── Arg parsing ──────────────────────────────────────────────────────────────── +const args = process.argv.slice(2); +let inputFile = null, outFile = null, waitMs = 800; + +for (let i = 0; i < args.length; i++) { + if (args[i] === "--input" && args[i + 1]) { inputFile = args[++i]; } + else if (args[i] === "--out" && args[i + 1]) { outFile = args[++i]; } + else if (args[i] === "--wait" && args[i + 1]) { waitMs = parseInt(args[++i], 10); } +} + +if (!inputFile || !outFile) usage(); +if (!fs.existsSync(inputFile)) { + console.error(JSON.stringify({ status: "error", error: `File not found: ${inputFile}` })); + process.exit(1); +} + +// ── Playwright loader (tolerates global npm installs) ───────────────────────── +function loadPlaywright() { + const { execSync } = require("child_process"); + try { return require("playwright"); } catch (_) {} + try { + const root = execSync("npm root -g", { stdio: ["ignore","pipe","ignore"] }).toString().trim(); + return require(path.join(root, "playwright")); + } catch (_) {} + console.error(JSON.stringify({ + status: "error", + error: "playwright not found", + hint: "Run: npm install -g playwright && npx playwright install chromium" + })); + process.exit(2); +} + +// ── Main ─────────────────────────────────────────────────────────────────────── +(async () => { + const { chromium } = loadPlaywright(); + + let browser; + try { + browser = await chromium.launch(); + } catch (e) { + // Chromium binary missing — try installing + const { spawnSync } = require("child_process"); + const r = spawnSync("npx", ["playwright", "install", "chromium"], { stdio: "inherit", shell: true }); + if (r.status !== 0) { + console.error(JSON.stringify({ + status: "error", + error: "Chromium not installed and auto-install failed", + hint: "Run: npx playwright install chromium" + })); + process.exit(2); + } + browser = await chromium.launch(); + } + + try { + const page = await browser.newPage(); + const fileUrl = "file://" + path.resolve(inputFile); + await page.goto(fileUrl); + await page.waitForTimeout(waitMs); // let CSS + any JS settle + + await page.pdf({ + path: outFile, + width: "794px", + height: "1123px", + printBackground: true, + }); + + await browser.close(); + + // Basic sanity: output file must exist and be > 5 KB + const stat = fs.statSync(outFile); + if (stat.size < 5000) { + console.error(JSON.stringify({ + status: "error", + error: "Output PDF is suspiciously small — cover may be blank", + hint: "Check cover.html for render errors" + })); + process.exit(3); + } + + console.log(JSON.stringify({ + status: "ok", + out: outFile, + size_kb: Math.round(stat.size / 1024), + })); + + } catch (e) { + if (browser) await browser.close().catch(() => {}); + console.error(JSON.stringify({ status: "error", error: String(e) })); + process.exit(3); + } +})(); diff --git a/backend/app/skills_builtin/minimax-xlsx/SKILL.md b/backend/app/skills_builtin/minimax-xlsx/SKILL.md new file mode 100644 index 0000000..0d02ceb --- /dev/null +++ b/backend/app/skills_builtin/minimax-xlsx/SKILL.md @@ -0,0 +1,138 @@ +--- +name: minimax-xlsx +description: "Open, create, read, analyze, edit, or validate Excel/spreadsheet files (.xlsx, .xlsm, .csv, .tsv). Use when the user asks to create, build, modify, analyze, read, validate, or format any Excel spreadsheet, financial model, pivot table, or tabular data file. Covers: creating new xlsx from scratch, reading and analyzing existing files, editing existing xlsx with zero format loss, formula recalculation and validation, and applying professional financial formatting standards. Triggers on 'spreadsheet', 'Excel', '.xlsx', '.csv', 'pivot table', 'financial model', 'formula', or any request to produce tabular data in Excel format." +license: MIT +metadata: + version: "1.0" + category: productivity + sources: + - ECMA-376 Office Open XML File Formats + - Microsoft Open XML SDK documentation +--- + +# MiniMax XLSX Skill + +Handle the request directly. Do NOT spawn sub-agents. Always write the output file the user requests. + +## Task Routing + +| Task | Method | Guide | +|------|--------|-------| +| **READ** — analyze existing data | `xlsx_reader.py` + pandas | `references/read-analyze.md` | +| **CREATE** — new xlsx from scratch | XML template | `references/create.md` + `references/format.md` | +| **EDIT** — modify existing xlsx | XML unpack→edit→pack | `references/edit.md` (+ `format.md` if styling needed) | +| **FIX** — repair broken formulas in existing xlsx | XML unpack→fix `` nodes→pack | `references/fix.md` | +| **VALIDATE** — check formulas | `formula_check.py` | `references/validate.md` | + +## READ — Analyze data (read `references/read-analyze.md` first) + +Start with `xlsx_reader.py` for structure discovery, then pandas for custom analysis. Never modify the source file. + +**Formatting rule**: When the user specifies decimal places (e.g. "2 decimal places"), apply that format to ALL numeric values — use `f'{v:.2f}'` on every number. Never output `12875` when `12875.00` is required. + +**Aggregation rule**: Always compute sums/means/counts directly from the DataFrame column — e.g. `df['Revenue'].sum()`. Never re-derive column values before aggregation. + +## CREATE — XML template (read `references/create.md` + `references/format.md`) + +Copy `templates/minimal_xlsx/` → edit XML directly → pack with `xlsx_pack.py`. Every derived value MUST be an Excel formula (`SUM(B2:B9)`), never a hardcoded number. Apply font colors per `format.md`. + +## EDIT — XML direct-edit (read `references/edit.md` first) + +**CRITICAL — EDIT INTEGRITY RULES:** +1. **NEVER create a new `Workbook()`** for edit tasks. Always load the original file. +2. The output MUST contain the **same sheets** as the input (same names, same data). +3. Only modify the specific cells the task asks for — everything else must be untouched. +4. **After saving output.xlsx, verify it**: open with `xlsx_reader.py` or `pandas` and confirm the original sheet names and a sample of original data are present. If verification fails, you wrote the wrong file — fix it before delivering. + +Never use openpyxl round-trip on existing files (corrupts VBA, pivots, sparklines). Instead: unpack → use helper scripts → repack. + +**"Fill cells" / "Add formulas to existing cells" = EDIT task.** If the input file already exists and you are told to fill, update, or add formulas to specific cells, you MUST use the XML edit path. Never create a new `Workbook()`. Example — fill B3 with a cross-sheet SUM formula: +```bash +python3 SKILL_DIR/scripts/xlsx_unpack.py input.xlsx /tmp/xlsx_work/ +# Find the target sheet's XML via xl/workbook.xml → xl/_rels/workbook.xml.rels +# Then use the Edit tool to add inside the target element: +# SUM('Sales Data'!D2:D13) +python3 SKILL_DIR/scripts/xlsx_pack.py /tmp/xlsx_work/ output.xlsx +``` + +**Add a column** (formulas, numfmt, styles auto-copied from adjacent column): +```bash +python3 SKILL_DIR/scripts/xlsx_unpack.py input.xlsx /tmp/xlsx_work/ +python3 SKILL_DIR/scripts/xlsx_add_column.py /tmp/xlsx_work/ --col G \ + --sheet "Sheet1" --header "% of Total" \ + --formula '=F{row}/$F$10' --formula-rows 2:9 \ + --total-row 10 --total-formula '=SUM(G2:G9)' --numfmt '0.0%' \ + --border-row 10 --border-style medium +python3 SKILL_DIR/scripts/xlsx_pack.py /tmp/xlsx_work/ output.xlsx +``` +The `--border-row` flag applies a top border to ALL cells in that row (not just the new column). Use it when the task requires accounting-style borders on total rows. + +**Insert a row** (shifts existing rows, updates SUM formulas, fixes circular refs): +```bash +python3 SKILL_DIR/scripts/xlsx_unpack.py input.xlsx /tmp/xlsx_work/ +# IMPORTANT: Find the correct --at row by searching for the label text +# in the worksheet XML, NOT by using the row number from the prompt. +# The prompt may say "row 5 (Office Rent)" but Office Rent might actually +# be at row 4. Always locate the row by its text label first. +python3 SKILL_DIR/scripts/xlsx_insert_row.py /tmp/xlsx_work/ --at 5 \ + --sheet "Budget FY2025" --text A=Utilities \ + --values B=3000 C=3000 D=3500 E=3500 \ + --formula 'F=SUM(B{row}:E{row})' --copy-style-from 4 +python3 SKILL_DIR/scripts/xlsx_pack.py /tmp/xlsx_work/ output.xlsx +``` +**Row lookup rule**: When the task says "after row N (Label)", always find the row by searching for "Label" in the worksheet XML (`grep -n "Label" /tmp/xlsx_work/xl/worksheets/sheet*.xml` or check sharedStrings.xml). Use the actual row number + 1 for `--at`. Do NOT call `xlsx_shift_rows.py` separately — `xlsx_insert_row.py` calls it internally. + +**Apply row-wide borders** (e.g. accounting line on a TOTAL row): +After running helper scripts, apply borders to ALL cells in the target row, not just newly added cells. In `xl/styles.xml`, append a new `` with the desired style, then append a new `` in `` that clones each cell's existing `` but sets the new `borderId`. Apply the new style index to every `` in the row via the `s` attribute: +```xml + + + + + +``` +**Key rule**: When a task says "add a border to row N", iterate over ALL cells A through the last column, not just newly added cells. + +**Manual XML edit** (for anything the helper scripts don't cover): +```bash +python3 SKILL_DIR/scripts/xlsx_unpack.py input.xlsx /tmp/xlsx_work/ +# ... edit XML with the Edit tool ... +python3 SKILL_DIR/scripts/xlsx_pack.py /tmp/xlsx_work/ output.xlsx +``` + +## FIX — Repair broken formulas (read `references/fix.md` first) + +This is an EDIT task. Unpack → fix broken `` nodes → pack. Preserve all original sheets and data. + +## VALIDATE — Check formulas (read `references/validate.md` first) + +Run `formula_check.py` for static validation. Use `libreoffice_recalc.py` for dynamic recalculation when available. + +## Financial Color Standard + +| Cell Role | Font Color | Hex Code | +|-----------|-----------|----------| +| Hard-coded input / assumption | Blue | `0000FF` | +| Formula / computed result | Black | `000000` | +| Cross-sheet reference formula | Green | `00B050` | + +## Key Rules + +1. **Formula-First**: Every calculated cell MUST use an Excel formula, not a hardcoded number +2. **CREATE → XML template**: Copy minimal template, edit XML directly, pack with `xlsx_pack.py` +3. **EDIT → XML**: Never openpyxl round-trip. Use unpack/edit/pack scripts +4. **Always produce the output file** — this is the #1 priority +5. **Validate before delivery**: `formula_check.py` exit code 0 = safe + +## Utility Scripts + +```bash +python3 SKILL_DIR/scripts/xlsx_reader.py input.xlsx # structure discovery +python3 SKILL_DIR/scripts/formula_check.py file.xlsx --json # formula validation +python3 SKILL_DIR/scripts/formula_check.py file.xlsx --report # standardized report +python3 SKILL_DIR/scripts/xlsx_unpack.py in.xlsx /tmp/work/ # unpack for XML editing +python3 SKILL_DIR/scripts/xlsx_pack.py /tmp/work/ out.xlsx # repack after editing +python3 SKILL_DIR/scripts/xlsx_shift_rows.py /tmp/work/ insert 5 1 # shift rows for insertion +python3 SKILL_DIR/scripts/xlsx_add_column.py /tmp/work/ --col G ... # add column with formulas +python3 SKILL_DIR/scripts/xlsx_insert_row.py /tmp/work/ --at 6 ... # insert row with data +``` diff --git a/backend/app/skills_builtin/minimax-xlsx/references/create.md b/backend/app/skills_builtin/minimax-xlsx/references/create.md new file mode 100644 index 0000000..8dd42be --- /dev/null +++ b/backend/app/skills_builtin/minimax-xlsx/references/create.md @@ -0,0 +1,691 @@ +# Build New xlsx from Scratch + +Create new, production-quality xlsx files using the XML approach. NEVER use openpyxl +for writing. NEVER hardcode Python-computed values — every derived number must be a +live Excel formula. + +--- + +## When to Use This Path + +Use this document when the user wants: +- A brand-new Excel file that does not yet exist +- A generated report, financial model, or data table +- Any "create / build / generate / make" request + +If the user provides an existing file to modify, switch to `edit.md` instead. + +--- + +## The Non-Negotiable Rules + +Before touching any file, internalize these four rules: + +1. **Formula-First**: Every calculated value (`SUM`, growth rate, ratio, subtotal, etc.) + MUST be written as `SUM(B2:B9)`, not as a hardcoded `5000`. Hardcoded + numbers go stale when source data changes. Only raw inputs and assumption parameters + may be hardcoded values. + +2. **No openpyxl for writing**: The entire file is built by editing XML directly. Python + is only allowed for reading/analysis (`pandas.read_excel()`) and for running helper + scripts (`xlsx_pack.py`, `formula_check.py`). + +3. **Style encodes meaning**: Blue font = user input/assumption. Black font = formula + result. Green font = cross-sheet reference. See `format.md` for the full color system + and style index table. + +4. **Validate before delivery**: Run `formula_check.py` and fix all errors before + handing the file to the user. + +--- + +## Complete Creation Workflow + +### Step 1 — Plan Before Writing + +Define the full structure on paper before touching any XML: + +- **Sheets**: names, order, purpose (e.g., Assumptions / Model / Summary) +- **Layout per sheet**: which rows are headers, inputs, formulas, totals +- **String inventory**: collect all text labels you will need in sharedStrings +- **Style choices**: what number format each column needs (currency, %, integer, year) +- **Cross-sheet links**: which sheets pull data from other sheets + +This planning step prevents the costly cycle of adding strings to sharedStrings +mid-way and recomputing all indices. + +--- + +### Step 2 — Copy Minimal Template + +```bash +cp -r SKILL_DIR/templates/minimal_xlsx/ /tmp/xlsx_work/ +``` + +The template gives you a complete, valid 7-file xlsx skeleton: + +``` +/tmp/xlsx_work/ +├── [Content_Types].xml ← MIME type registry +├── _rels/ +│ └── .rels ← root relationship (points to workbook.xml) +└── xl/ + ├── workbook.xml ← sheet list and calc settings + ├── styles.xml ← 13 pre-built financial style slots + ├── sharedStrings.xml ← text string table (starts empty) + ├── _rels/ + │ └── workbook.xml.rels ← maps rId → file paths + └── worksheets/ + └── sheet1.xml ← one empty sheet +``` + +After copying, rename sheets and add content. Do not create files from scratch — +always start from the template. + +--- + +### Step 3 — Configure Sheet Structure + +#### Single-Sheet Workbook + +The template already has one sheet named "Sheet1". Just change the `name` attribute +in `xl/workbook.xml`: + +```xml + + + +``` + +No other files need to change for a single-sheet workbook. + +#### Multi-Sheet Workbook + +Four files must be kept in sync. Work through them in this order: + +**IMPORTANT — rId collision rule**: In the template's `workbook.xml.rels`, the IDs +`rId1`, `rId2`, and `rId3` are already taken: +- `rId1` → `worksheets/sheet1.xml` +- `rId2` → `styles.xml` +- `rId3` → `sharedStrings.xml` + +New worksheet entries MUST start at `rId4` and count upward. + +**File 1 of 4 — `xl/workbook.xml`** (sheet list): + +```xml + + + + + +``` + +Special characters in sheet names: +- `&` → `&` in XML: `` +- Max 31 characters +- Forbidden: `/ \ ? * [ ] :` +- Sheet names with spaces need single quotes in formula references: `'Q1 Data'!B5` + +**File 2 of 4 — `xl/_rels/workbook.xml.rels`** (ID → file mapping): + +```xml + + + + + + + +``` + +**File 3 of 4 — `[Content_Types].xml`** (MIME type declarations): + +```xml + + + + + + + + + + +``` + +**File 4 of 4 — Create new worksheet XML files** + +Copy `sheet1.xml` to `sheet2.xml` and `sheet3.xml`, then clear the `` content: + +```xml + + + + + + + + + + + +``` + +**Sync checklist** — every time you add a sheet, verify all four are consistent: + +| Check | What to verify | +|-------|---------------| +| `workbook.xml` | New `` exists | +| `workbook.xml.rels` | New `` exists | +| `[Content_Types].xml` | New `` exists | +| Filesystem | `xl/worksheets/sheetN.xml` file actually exists | + +--- + +### Step 4 — Populate sharedStrings + +All text values (headers, row labels, category names, any string the user will read) +must be stored in `xl/sharedStrings.xml`. Cells reference them by 0-based index. + +**Recommended workflow**: collect ALL text you need first, write the complete table once, +then fill in indices while writing worksheet XML. This avoids re-counting indices mid-way. + +```xml + + + Item + FY2023A + FY2024E + FY2025E + YoY Growth + Revenue + Cost of Goods Sold + Gross Profit + EBITDA + Net Income + +``` + +**Attribute rules**: +- `uniqueCount` = number of `` elements (unique strings in the table) +- `count` = total number of cell references to strings across the entire workbook + (if "Revenue" appears in 3 sheets, count is `uniqueCount + 2`) +- For new files where each string appears once, `count == uniqueCount` +- Both attributes MUST be accurate — wrong values trigger warnings in some Excel versions + +**Special character escaping**: + +```xml +R&D Expenses +Revenue < Target + (note) +``` + +**Helper script**: use `shared_strings_builder.py` to generate the complete +`sharedStrings.xml` from a plain list of strings: + +```bash +python3 SKILL_DIR/scripts/shared_strings_builder.py \ + "Item" "FY2024" "FY2025" "Revenue" "Gross Profit" \ + > /tmp/xlsx_work/xl/sharedStrings.xml +``` + +Or interactively from a file listing one string per line: + +```bash +python3 SKILL_DIR/scripts/shared_strings_builder.py --file strings.txt \ + > /tmp/xlsx_work/xl/sharedStrings.xml +``` + +--- + +### Step 5 — Write Worksheet Data + +Edit each `xl/worksheets/sheetN.xml`. Replace the empty `` with rows +and cells. + +#### Cell XML Anatomy + +``` + + ↑ ↑ ↑ + address type style index (from cellXfs in styles.xml) + + 3 + ↑ + value (for t="s": sharedStrings index; for numbers: the number itself) +``` + +#### Data Type Reference + +| Data | `t` attr | XML Example | Notes | +|------|---------|-------------|-------| +| Shared string (text) | `s` | `0` | `` = sharedStrings index | +| Number | omit | `1000000` | default type, `t` omitted | +| Percentage (as decimal) | omit | `0.125` | 12.5% stored as 0.125 | +| Boolean | `b` | `1` | 1=TRUE, 0=FALSE | +| Formula | omit | `SUM(B2:B3)` | `` left empty | +| Cross-sheet formula | omit | `Assumptions!B2` | use s=3 (green) | + +#### A Full Sheet Data Example + +```xml + + + + + + + + + 0 + 1 + 2 + 3 + 4 + + + + + 5 + 85000000 + B2*(1+Assumptions!C3) + C2*(1+Assumptions!D3) + D2/C2-1 + + + + + 7 + B2*Assumptions!B4 + C2*Assumptions!C4 + D2*Assumptions!D4 + D3/C3-1 + + + + + 8 + SUM(B2:B4) + SUM(C2:C4) + SUM(D2:D4) + D5/C5-1 + + + +``` + +#### Column Width and Freeze Pane + +Column widths go **before** ``, freeze pane goes inside ``: + +```xml + + + + + + + + +``` + +--- + +### Step 6 — Apply Styles + +The template's `xl/styles.xml` has 13 pre-built semantic style slots (indices 0–12). +**Read `format.md` for the complete style index table, color system, and how to add new styles.** + +Quick reference for the most common slots: + +| `s` | Role | Example | +|-----|------|---------| +| 4 | Header (bold) | Column/row titles | +| 5 / 6 | Currency input (blue) / formula (black) | `$#,##0` | +| 7 / 8 | Percentage input / formula | `0.0%` | +| 11 | Year (no comma) | 2024 not 2,024 | + +Design principle: Blue = human sets this. Black = Excel computes this. Green = cross-sheet. + +If you need a style not in the 13 pre-built slots, follow the append-only procedure in `format.md` section 3.2. + +--- + +### Step 7 — Formula Cookbook + +#### XML Formula Syntax Reminder + +Formulas in XML have **no leading `=`**: + +```xml + +SUM(B2:B9) +``` + +#### Basic Aggregations + +```xml +SUM(B2:B9) +AVERAGE(B2:B9) +COUNT(B2:B9) +COUNTA(A2:A100) +MAX(B2:B9) +MIN(B2:B9) +``` + +#### Financial Calculations + +```xml + +D5/C5-1 + + +B4*B3 + + +B8/B4 + + +IF(C5=0,0,D5/C5-1) + + +NPV(B1,B3:B7)+B2 +IRR(B2:B7) +``` + +#### Cross-Sheet References + +```xml + +Assumptions!B5 + + +'Q1 Data'!B5 + + +'R&D'!B5 + + +SUM(Data!C2:C1000) + + +SUM(Jan:Dec!B5) +``` + +Cross-sheet formula cells should use `s="3"` (green) to signal the data origin. + +#### Shared Formulas (Same Pattern Repeated Down a Column) + +When many consecutive cells share the same formula structure with only the row number +changing, use shared formulas to keep the XML compact: + +```xml + +C2/B2-1 + + + + + + + + + + + +``` + +Excel adjusts relative references automatically (D3 computes `C3/B3-1`, etc.). +If you have multiple shared formula groups, assign sequential `si` values (0, 1, 2, …). + +#### Absolute References + +```xml + +B5/$B$2 +``` + +The `$` character needs no XML escaping — write it literally. + +#### Lookup Formulas + +```xml + +VLOOKUP(A5,Assumptions!A:C,2,0) + + +INDEX(B:B,MATCH(A5,A:A,0)) + + +XLOOKUP(A5,A:A,B:B) +``` + +--- + +### Step 8 — Pack and Validate + +**Pack**: + +```bash +python3 SKILL_DIR/scripts/xlsx_pack.py /tmp/xlsx_work/ /path/to/output.xlsx +``` + +`xlsx_pack.py` will: +1. Check that `[Content_Types].xml` exists at the root +2. Parse every `.xml` and `.rels` file for well-formedness — abort if any fail +3. Create the ZIP archive with correct compression + +**Validate**: + +```bash +python3 SKILL_DIR/scripts/formula_check.py /path/to/output.xlsx +``` + +`formula_check.py` will: +1. Scan every cell for `` entries (cached error values) — all 7 error types +2. Extract sheet name references from every `` formula +3. Verify each referenced sheet exists in `workbook.xml` + +Fix every reported error before delivery. Exit code 0 = safe to deliver. + +--- + +## Pre-Delivery Checklist + +Run through this list before handing the file to the user: + +- [ ] `formula_check.py` reports 0 errors +- [ ] Every calculated cell has `` — not just `` with a number +- [ ] `sharedStrings.xml` `count` and `uniqueCount` match actual `` count +- [ ] Every cell `s` attribute value is in range `0` to `cellXfs count - 1` +- [ ] Every sheet in `workbook.xml` has a matching entry in `workbook.xml.rels` +- [ ] Every `worksheets/sheetN.xml` file has a matching `` in `[Content_Types].xml` +- [ ] Year columns use `s="11"` (format `0`, no thousands separator) +- [ ] Cross-sheet reference formulas use `s="3"` (green font) +- [ ] Assumption inputs use `s="1"` or `s="5"` or `s="7"` (blue font) + +--- + +## Common Mistakes and Fixes + +| Mistake | Symptom | Fix | +|---------|---------|-----| +| Formula has leading `=` | Cell shows `=SUM(...)` as text | Remove `=` from `` content | +| sharedStrings `count` not updated | Excel warning or blank cells | Count `` elements, update both `count` and `uniqueCount` | +| Style index out of range | File corruption / Excel repair | Ensure `s` < `cellXfs count`; append new `` if needed | +| New sheet rId conflicts with styles/sharedStrings rId | Sheet missing or styles lost | New sheets use rId4, rId5, … (rId1-3 are reserved in template) | +| Sheet name has `&` unescaped in XML | XML parse error | Use `&` in `workbook.xml` name attribute | +| Cross-sheet ref to sheet with space, no quotes | `#REF!` error | Wrap sheet name in single quotes: `'Sheet Name'!B5` | +| Cross-sheet ref to non-existent sheet | `#REF!` error | Check `workbook.xml` sheet list vs formula | +| Number stored as text (`t="s"`) | Left-aligned, can't sum | Remove `t` attribute from number cells | +| Year displayed as `2,024` | Readability issue | Use `s="11"` (numFmtId=1, format `0`) | +| Hardcoded Python result instead of formula | "Dead table" — won't update | Replace `N` with `formula` | + +--- + +## Column Letter Reference + +| Col # | Letter | Col # | Letter | Col # | Letter | +|-------|--------|-------|--------|-------|--------| +| 1 | A | 26 | Z | 27 | AA | +| 28 | AB | 52 | AZ | 53 | BA | +| 54 | BB | 78 | BZ | 79 | CA | + +Python conversion (use when building formulas programmatically): + +```python +def col_letter(n: int) -> str: + """Convert 1-based column number to Excel letter (A, B, ..., Z, AA, AB, ...).""" + result = "" + while n > 0: + n, rem = divmod(n - 1, 26) + result = chr(65 + rem) + result + return result + +def col_number(s: str) -> int: + """Convert Excel column letter to 1-based number.""" + n = 0 + for c in s.upper(): + n = n * 26 + (ord(c) - 64) + return n +``` + +--- + +## Typical Scenario Walkthroughs + +### Scenario A — Three-Year Financial Model (Single Sheet) + +Layout: rows 1-12 = Assumptions (blue inputs) / rows 14-30 = Model (black formulas). + +```xml + + + Metric + FY2023A + FY2024E + FY2025E + Revenue Growth + Gross Margin + Revenue + Gross Profit + + + + + + + 0 + 1 + 2 + 3 + + + + 4 + 0 + 0.12 + 0.15 + + + 5 + 0.45 + 0.46 + 0.47 + + + + 6 + 85000000 + B14*(1+C2) + C14*(1+D2) + + + 7 + B14*B3 + C14*C3 + D14*D3 + + +``` + +### Scenario B — Data + Summary (Two Sheets) + +The `Summary` sheet pulls from `Data` using cross-sheet formulas (green, `s="3"`): + +```xml + + + + 0 + 1 + + + 2 + SUM(Data!C2:C10000) + + + 3 + COUNTA(Data!A2:A10000) + + + 4 + IF(B3=0,0,B2/B3) + + +``` + +### Scenario C — Multi-Department Consolidation + +`Consolidated` sheet sums the same cells from multiple department sheets: + +```xml + + + + 0 + + Dept_Engineering!B5+Dept_Marketing!B5 + + + 1 + SUM(Dept_Engineering!B6,Dept_Marketing!B6) + + +``` + +--- + +## What You Must NOT Do + +- Do NOT use openpyxl or any Python library to write the final xlsx file +- Do NOT hardcode any calculated value — use `` formulas for every derived number +- Do NOT deliver without running `formula_check.py` first +- Do NOT set a cell's `s` attribute to a value >= `cellXfs count` +- Do NOT modify an existing `` entry in `styles.xml` — only append new ones +- Do NOT add a new sheet without updating all four sync points (workbook.xml, + workbook.xml.rels, [Content_Types].xml, actual .xml file) +- Do NOT assign new worksheet rIds that overlap with rId1, rId2, or rId3 (reserved + for sheet1, styles, sharedStrings in the template) diff --git a/backend/app/skills_builtin/minimax-xlsx/references/edit.md b/backend/app/skills_builtin/minimax-xlsx/references/edit.md new file mode 100644 index 0000000..ca70971 --- /dev/null +++ b/backend/app/skills_builtin/minimax-xlsx/references/edit.md @@ -0,0 +1,684 @@ +# Minimal-Invasive Editing of Existing xlsx + +Make precise, surgical changes to existing xlsx files while preserving everything you do not touch: styles, macros, pivot tables, charts, sparklines, named ranges, data validation, conditional formatting, and all other embedded content. + +--- + +## 1. When to Use This Path + +Use the edit (unpack → XML edit → pack) path whenever the task involves **modifying an existing xlsx file**: + +- Template filling — populating designated input cells with values or formulas +- Data updates — replacing outdated numbers, text, or dates in a live file +- Content corrections — fixing wrong values, broken formulas, or mistyped labels +- Adding new data rows to an existing table +- Renaming a sheet +- Applying a new style to specific cells + +Do NOT use this path for creating a brand-new workbook from scratch. For that, see `create.md`. + +--- + +## 2. Why openpyxl round-trip Is Forbidden for Existing Files + +openpyxl `load_workbook()` followed by `workbook.save()` is a **destructive operation** on any file that contains advanced features. The library silently drops content it does not understand: + +| Feature | openpyxl behavior | Consequence | +|---------|-------------------|-------------| +| VBA macros (`vbaProject.bin`) | Dropped entirely | All automation is lost; file saved as `.xlsx` not `.xlsm` | +| Pivot tables (`xl/pivotTables/`) | Dropped | Interactive analysis is destroyed | +| Slicers | Dropped | Filter UI is lost | +| Sparklines (``) | Dropped | In-cell mini-charts disappear | +| Chart formatting details | Partially lost | Series colors, custom axes may revert | +| Print area / page breaks | Sometimes lost | Print layout changes | +| Custom XML parts | Dropped | Third-party data bindings broken | +| Theme-linked colors | May be de-themed | Colors converted to absolute, breaking theme switching | + +Even on a "plain" file without these features, openpyxl may normalize whitespace in XML that Excel relies on, alter namespace declarations, or reset `calcMode` flags. + +**The rule is absolute: never open an existing file with openpyxl for the purpose of re-saving it.** + +The XML direct-edit approach is safe because it operates on the raw bytes. You only change the nodes you touch. Everything else is byte-equivalent to the original. + +--- + +## 3. Standard Operating Procedure + +### Step 1 — Unpack + +```bash +python3 SKILL_DIR/scripts/xlsx_unpack.py input.xlsx /tmp/xlsx_work/ +``` + +The script unzips the xlsx, pretty-prints every XML and `.rels` file, and prints a categorized inventory of key files plus a warning if high-risk content is detected (VBA, pivot tables, charts). + +Read the printed output carefully before proceeding. If the script reports `xl/vbaProject.bin` or `xl/pivotTables/`, follow the constraints in Section 7. + +### Step 2 — Reconnaissance + +Map the structure before touching anything. + +**Identify sheet names and their XML files:** + +``` +xl/workbook.xml → +xl/_rels/workbook.xml.rels → +``` + +The sheet named "Revenue" lives in `xl/worksheets/sheet1.xml`. Always resolve this mapping before editing a worksheet. + +**Understand the shared strings table:** + +```bash +# Count existing entries in xl/sharedStrings.xml +grep -c "" /tmp/xlsx_work/xl/sharedStrings.xml +``` + +Every text cell uses a zero-based index into this table. Know the current count before appending. + +**Understand the styles table:** + +```bash +# Count existing cellXfs entries +grep -c "` — merged cell ranges; row/column insertion shifts these +- `` — condition ranges; row/column insertion shifts these +- `` — validation ranges; row/column insertion shifts these +- `` — table definitions; row insertion inside a table needs `` updates +- `` — sparklines; preserve without modification + +### Step 3 — Map Intent to Minimal XML Changes + +Before writing a single character, produce a written list of exactly which XML nodes change. This prevents scope creep. + +| User intent | Files to change | Nodes to change | +|-------------|----------------|-----------------| +| Change a cell's numeric value | `xl/worksheets/sheetN.xml` | `` inside target `` | +| Change a cell's text | `xl/sharedStrings.xml` (append) + `xl/worksheets/sheetN.xml` | New ``, update cell `` index | +| Change a cell's formula | `xl/worksheets/sheetN.xml` | `` text inside target `` | +| Add a new data row at the bottom | `xl/worksheets/sheetN.xml` + possibly `xl/sharedStrings.xml` | Append `` element | +| Apply a new style to cells | `xl/styles.xml` + `xl/worksheets/sheetN.xml` | Append `` in ``, update `s` attribute on `` | +| Rename a sheet | `xl/workbook.xml` | `name` attribute on `` element | +| Rename a sheet (with cross-sheet formulas) | `xl/workbook.xml` + all `xl/worksheets/*.xml` | `name` attribute + `` text referencing old name | + +### Step 4 — Execute Changes + +Use the Edit tool. Edit the minimum. Never rewrite whole files. + +See Section 4 for precise XML patterns for each operation type. + +### Step 5 — Cascade Check + +After any change that shifts row or column positions, audit all affected XML regions. See Section 5. + +### Step 6 — Pack and Validate + +```bash +python3 SKILL_DIR/scripts/xlsx_pack.py /tmp/xlsx_work/ output.xlsx +python3 SKILL_DIR/scripts/formula_check.py output.xlsx +``` + +The pack script validates XML well-formedness before creating the ZIP. Fix any reported parse errors before packing. After packing, run `formula_check.py` to confirm no formula errors were introduced. + +--- + +## 4. Precise XML Patterns for Common Edits + +### 4.1 Changing a Numeric Cell Value + +Find the `` element in the worksheet XML and replace the `` text. + +**Before:** +```xml + + 1000 + +``` + +**After (new value 1500):** +```xml + + 1500 + +``` + +Rules: +- Do not add or remove the `s` attribute (style) unless explicitly changing the style. +- Do not add a `t` attribute — numbers omit `t` or use `t="n"`. +- Do not change the `r` attribute (cell reference). + +--- + +### 4.2 Changing a Text Cell Value + +Text cells reference the shared strings table by index (`t="s"`). You cannot edit the string in-place without affecting every other cell that uses the same index. The safe approach is to append a new entry. + +**Before — shared strings file (`xl/sharedStrings.xml`):** +```xml + + Revenue + Cost + Margin + Old Label + +``` + +**After — append new string, increment counts:** +```xml + + Revenue + Cost + Margin + Old Label + New Label + +``` + +New string is at index 4 (zero-based). + +**Before — cell in worksheet XML:** +```xml + + 3 + +``` + +**After — point to new index:** +```xml + + 4 + +``` + +Rules: +- Never modify or delete existing `` entries. Only append. +- Both `count` and `uniqueCount` must be incremented together. +- If the new string contains `&`, `<`, or `>`, escape them: `&`, `<`, `>`. +- If the string has leading or trailing spaces, add `xml:space="preserve"` to ``: + ```xml + indented text + ``` + +--- + +### 4.3 Changing a Formula + +Formulas are stored in `` elements **without a leading `=`** (unlike what you type in Excel's UI). + +**Before:** +```xml + + SUM(C2:C9) + 4800 + +``` + +**After (extended range):** +```xml + + SUM(C2:C11) + + +``` + +Rules: +- Clear `` to an empty string when changing the formula. The cached value is now stale. +- Do not add `t="s"` or any type attribute to formula cells. The `t` attribute is absent or uses a result-type value, not a formula marker. +- Cross-sheet references use `SheetName!CellRef`. If the sheet name contains spaces, wrap in single quotes: `'Q1 Data'!B5`. +- The `` text must not include the leading `=`. + +**Before (converting a hardcoded value to a live formula):** +```xml + + 95000 + +``` + +**After:** +```xml + + SUM(D2:D14) + + +``` + +--- + +### 4.4 Adding a New Data Row + +Append after the last `` element inside ``. Row numbers in OOXML are 1-based and must be sequential. + +**Before (last row is row 10):** +```xml + + 3 + 2023 + 88000 + C10*1.1 + + +``` + +**After (new row 11 appended):** +```xml + + 3 + 2023 + 88000 + C10*1.1 + + + 4 + 2024 + 96000 + C11*1.1 + + +``` + +Rules: +- Every `` inside the row must have `r` set to the correct cell address (e.g., `A11`). +- Text cells need `t="s"` and a sharedStrings index in ``. Numeric cells omit `t`. +- Formula cells use `` and an empty ``. +- Copy the `s` attribute from the row above if you want matching styles. Do not invent a style index that does not exist in `styles.xml`. +- If the sheet contains a `` element (e.g., ``), update it to include the new row: ``. +- If the sheet contains a `` referencing a table, update the table's `ref` attribute in the corresponding `xl/tables/tableN.xml` file. + +--- + +### 4.5 Adding a New Column + +Append new `` elements to each existing `` and, if present, update the `` section. + +**Before (rows have columns A–C):** +```xml + + + + + + 0 + 1 + 2 + + + 100 + 200 + 300 + + +``` + +**After (adding column D):** +```xml + + + + + + + 0 + 1 + 2 + 5 + + + 100 + 200 + 300 + A2+B2+C2 + + +``` + +Rules: +- Adding a column at the end (after the last existing column) is safe — no existing formula references shift. +- Inserting a column in the middle shifts all columns to the right, which requires the same cascade updates as row insertion (see Section 5). +- Update the `` element if present. + +--- + +### 4.6 Modifying or Adding Styles + +Styles use a multi-level indirect reference chain. Read `ooxml-cheatsheet.md` for the full chain. The key rule: **only append new entries, never modify existing ones**. + +**Scenario:** Add a blue-font style (for hardcoded input cells) that doesn't yet exist. + +**Step 1 — Check if a matching font already exists in `xl/styles.xml`:** +```xml + + + + + +``` + +If found, note its index (zero-based position in the `` list). If not found, append. + +**Step 2 — Append the new font if needed:** + +Before: +```xml + + ... + ... + ... + +``` + +After: +```xml + + ... + ... + ... + + + + + + + +``` + +**Step 3 — Append a new `` in ``:** + +Before: +```xml + + + + + + + +``` + +After: +```xml + + + + + + + + +``` + +**Step 4 — Apply to target cells:** + +Before: +```xml + + 0.08 + +``` + +After: +```xml + + 0.08 + +``` + +Rules: +- Never delete or reorder existing entries in ``, ``, ``, ``. +- Always update the `count` attribute when appending. +- The new `cellXfs` index = the old `count` value before appending (zero-based: if count was 5, new index is 5). +- Custom `numFmt` IDs must be 164 or above. IDs 0–163 are built-in and must not be re-declared. +- If the desired style already exists elsewhere in the file (on a similar cell), reuse its `s` index rather than creating a duplicate. + +--- + +### 4.7 Renaming a Sheet + +**Only `xl/workbook.xml` needs to change** — unless cross-sheet formulas reference the old name. + +**Before (`xl/workbook.xml`):** +```xml + +``` + +**After:** +```xml + +``` + +**If any formula in any worksheet references the old name, update those too:** + +Before (`xl/worksheets/sheet2.xml`): +```xml +Sheet1!C10 +``` + +After: +```xml +Revenue!C10 +``` + +If the new name contains spaces: +```xml +'Q1 Revenue'!C10 +``` + +Scan all worksheet XML files for the old name: +```bash +grep -r "Sheet1!" /tmp/xlsx_work/xl/worksheets/ +``` + +Rules: +- The `.rels` file and `[Content_Types].xml` do NOT need to change — they reference the XML file path, not the sheet name. +- `sheetId` must not change; it is a stable internal identifier. +- Sheet names are case-sensitive in formula references. + +--- + +## 5. High-Risk Operations — Cascade Effects + +### 5.1 Inserting a Row in the Middle + +Inserting a row at position N shifts all rows from N downward. Every reference to those rows in every XML file must be updated. + +**Files to check and update:** + +| XML region | What to update | Example shift | +|------------|---------------|---------------| +| Worksheet `` attributes | Increment row number for all rows >= N | `r="7"` → `r="8"` | +| All `` within those rows | Increment row number in cell address | `r="A7"` → `r="A8"` | +| All `` formula text in any sheet | Shift absolute row references >= N | `B7` → `B8` | +| `` | Shift start and end rows | `A7:C7` → `A8:C8` | +| `` | Shift range | `A5:D20` → `A5:D21` | +| `` | Shift range | `B6:B50` → `B7:B51` | +| `xl/charts/chartN.xml` data source ranges | Shift series ranges | `Sheet1!$B$5:$B$20` → `Sheet1!$B$6:$B$21` | +| `xl/pivotTables/*.xml` source ranges | Shift source data range | Handle with extreme care — see Section 7 | +| `` | Expand to include new extent | `A1:D20` → `A1:D21` | +| `xl/tables/tableN.xml` `ref` attribute | Expand table boundary | `A1:D20` → `A1:D21` | + +**Do not attempt row insertion manually in large or formula-heavy files.** Use the dedicated shift script instead: + +```bash +# Insert 1 row at row 5: all rows 5 and below shift down by 1 +python3 SKILL_DIR/scripts/xlsx_shift_rows.py /tmp/xlsx_work/ insert 5 1 + +# Delete 1 row at row 8: all rows 9 and above shift up by 1 +python3 SKILL_DIR/scripts/xlsx_shift_rows.py /tmp/xlsx_work/ delete 8 1 +``` + +The script updates in one pass: `` attributes, `` cell addresses, all `` formula text across every worksheet, `` ranges, ``, ``, ``, table `ref` attributes in `xl/tables/`, chart series ranges in `xl/charts/`, and pivot cache source ranges in `xl/pivotCaches/`. + +**After running the shift script, always repack and validate:** +```bash +python3 SKILL_DIR/scripts/xlsx_pack.py /tmp/xlsx_work/ output.xlsx +python3 SKILL_DIR/scripts/formula_check.py output.xlsx +``` + +**What the script does NOT update (review manually):** +- Named ranges in `xl/workbook.xml` `` — check and update if they reference shifted rows. +- Structured table references (`Table[@Column]`) inside formulas. +- External workbook links in `xl/externalLinks/`. + +### 5.2 Inserting a Column in the Middle + +Same cascade logic as row insertion, but for columns. Column references in formulas (`B`, `$C`, etc.) and in merged cell ranges, conditional formatting ranges, and chart data sources all need updating. + +Column letter shifting is harder to automate safely. Prefer **appending columns at the end** whenever possible. + +### 5.3 Deleting a Row or Column + +Deletion is more dangerous than insertion because any formula that referenced a deleted row or column will become `#REF!`. Before deleting: + +1. Search all `` elements for references to the deleted range. +2. If any formula references a cell in the deleted row/column, do not delete — instead, either clear the row's data or consult the user. +3. After deletion, shift all references to rows/columns beyond the deletion point downward/leftward. + +--- + +## 6. Template Filling — Identifying and Populating Input Cells + +Templates designate certain cells as input zones. Common patterns to recognize them: + +### 6.1 How Templates Signal Input Zones + +| Signal | XML manifestation | What to look for | +|--------|-------------------|-----------------| +| Blue font color | `s` attribute pointing to a `cellXfs` entry with `fontId` → `` | Check `styles.xml` to decode `s` values | +| Yellow fill (highlight) | `s` → `fillId` → `` | | +| Empty `` element | `` or cell entirely absent from `` | The cell has no value yet | +| Comment/annotation near cell | `xl/comments1.xml` with `ref="B5"` | Comments often label input fields | +| Named ranges | `xl/workbook.xml` `` elements | Template may define `InputRevenue` etc. | + +### 6.2 Filling a Template Cell + +Do not change `s` attributes. Do not change `t` attributes unless you must change from empty to typed. Only change `` or add ``. + +**Before (empty input cell with style preserved):** +```xml + + + +``` + +**After (filled with a number, style unchanged):** +```xml + + 125000 + +``` + +**After (filled with text — requires shared string entry first):** +```xml + + + 7 + +``` + +**After (filled with a formula, preserving style):** +```xml + + Assumptions!D12 + + +``` + +### 6.3 Locating Input Zones Without Opening the File in Excel + +After unpacking, decode the style index on suspected input cells to determine if they have the template's input color: + +1. Note the `s` value on the cell (e.g., `s="4"`). +2. In `xl/styles.xml`, find `` and look at the 5th entry (index 4). +3. Note its `fontId` (e.g., `fontId="2"`). +4. In ``, look at the 3rd entry (index 2) and check for `` (blue) or other input marker. + +If the template uses named ranges as input fields, read them from `xl/workbook.xml`: +```xml + + Assumptions!$B$5 + Assumptions!$B$6 + +``` + +Fill the target cells (`Assumptions!B5`, `Assumptions!B6`) directly. + +### 6.4 Template Filling Rules + +- Fill only cells the template designated as inputs. Do not fill cells that are formula-driven. +- Do not apply new styles when filling. The template's formatting is the deliverable. +- Do not add or remove rows inside the template's data area unless the template explicitly has an "append here" zone. +- After filling, verify that no formula errors were introduced: some templates have input-validation formulas that produce `#VALUE!` if the wrong data type is entered. + +--- + +## 7. Files You Must Never Modify + +### 7.1 Absolute no-touch list + +| File / location | Why | +|-----------------|-----| +| `xl/vbaProject.bin` | Binary VBA bytecode. Any byte modification corrupts the macro project. Editing even one bit makes the macros fail to load. | +| `xl/pivotCaches/pivotCacheDefinition*.xml` | The cache definition ties the pivot table to its source data. Editing it without also updating the corresponding `pivotTable*.xml` will corrupt the pivot. | +| `xl/pivotTables/*.xml` | Pivot table XML is tightly coupled with the cache definition and with internal state Excel rebuilds on load. Do not edit. If you shifted rows and the pivot's source range now points to wrong data, update only the `` range in the cache definition, and only the `ref` attribute in the pivot table — no other changes. | +| `xl/slicers/*.xml` | Slicers are connected to specific cache IDs and pivot fields. Breaking these connections silently corrupts the file. | +| `xl/connections.xml` | External data connections. Editing breaks live data refresh. | +| `xl/externalLinks/` | External workbook links. The binary `.bin` files in here must not be modified. | + +### 7.2 Conditionally safe files (update only specific attributes) + +| File | What you may update | What to leave alone | +|------|--------------------|--------------------| +| `xl/charts/chartN.xml` | Data series range references (``) after a row/column shift | Chart type, formatting, layout | +| `xl/tables/tableN.xml` | `ref` attribute on `` after adding rows | Column definitions, style info | +| `xl/pivotCaches/pivotCacheDefinition*.xml` | `ref` attribute on `` after shifting source data | All other content | + +--- + +## 8. Validation After Every Edit + +Never skip validation. Even a one-character change in a formula can cause cascading errors. + +```bash +# Pack +python3 SKILL_DIR/scripts/xlsx_pack.py /tmp/xlsx_work/ output.xlsx + +# Static formula validation (always run) +python3 SKILL_DIR/scripts/formula_check.py output.xlsx + +# Dynamic validation (if LibreOffice available) +python3 SKILL_DIR/scripts/libreoffice_recalc.py output.xlsx /tmp/recalc.xlsx +python3 SKILL_DIR/scripts/formula_check.py /tmp/recalc.xlsx +``` + +If `formula_check.py` reports any error: +1. Unpack the output file again (it is the packed version). +2. Locate the reported cell in the worksheet XML. +3. Fix the `` element. +4. Repack and re-validate. + +Do not deliver the file until `formula_check.py` reports zero errors. + +--- + +## 9. Absolute Rules Summary + +| Rule | Rationale | +|------|-----------| +| Never use openpyxl `load_workbook` + `save` on an existing file | Round-trip destroys pivot tables, VBA, sparklines, slicers | +| Never delete or reorder existing `` entries in sharedStrings | Breaks every cell referencing that index | +| Never delete or reorder existing `` entries in `` | Breaks every cell using that style index | +| Never modify `vbaProject.bin` | Binary file; any change corrupts VBA | +| Never change `sheetId` when renaming a sheet | Internal ID is stable; changing it breaks relationships | +| Never skip post-edit validation | Leaves broken references undetected | +| Never edit more XML nodes than required | Extra changes risk introducing subtle corruption | +| Clear `` to empty string when changing a formula | Prevents stale cached value from misleading downstream consumers | +| Append-only to sharedStrings | Existing indexes must remain valid | +| Append-only to styles collections | Existing style indexes must remain valid | diff --git a/backend/app/skills_builtin/minimax-xlsx/references/fix.md b/backend/app/skills_builtin/minimax-xlsx/references/fix.md new file mode 100644 index 0000000..7aedc8a --- /dev/null +++ b/backend/app/skills_builtin/minimax-xlsx/references/fix.md @@ -0,0 +1,37 @@ +# FIX — Repair Broken Formulas in an Existing xlsx + +This is an EDIT task. You MUST preserve all original sheets and data. Never create a new workbook. + +## Workflow + +```bash +# Step 1: Identify errors +python3 SKILL_DIR/scripts/formula_check.py input.xlsx --json + +# Step 2: Unpack +python3 SKILL_DIR/scripts/xlsx_unpack.py input.xlsx /tmp/xlsx_work/ + +# Step 3: Fix each broken element in the worksheet XML using the Edit tool +# (see Error-to-Fix mapping below) + +# Step 4: Pack and validate +python3 SKILL_DIR/scripts/xlsx_pack.py /tmp/xlsx_work/ output.xlsx +python3 SKILL_DIR/scripts/formula_check.py output.xlsx +``` + +## Error-to-Fix Mapping + +| Error | Fix Strategy | +|-------|-------------| +| `#DIV/0!` | Wrap: `IFERROR(original_formula, "-")` | +| `#NAME?` | Fix misspelled function (e.g. `SUMM` → `SUM`) | +| `#REF!` | Reconstruct the broken reference | +| `#VALUE!` | Fix type mismatch | + +For the full list of Excel error types and advanced diagnostics, see `validate.md`. + +## Critical Rules + +- The output MUST contain the same sheets as the input. Do NOT create a new workbook. +- Only modify the specific `` elements that are broken — everything else must be untouched. +- After packing, always run `formula_check.py` to confirm all errors are resolved. diff --git a/backend/app/skills_builtin/minimax-xlsx/references/format.md b/backend/app/skills_builtin/minimax-xlsx/references/format.md new file mode 100644 index 0000000..a20723a --- /dev/null +++ b/backend/app/skills_builtin/minimax-xlsx/references/format.md @@ -0,0 +1,768 @@ +# Financial Formatting & Output Standards — Complete Agent Guide + +> This document is the complete reference manual for the agent when applying professional financial formatting to xlsx files. All operations target direct XML surgery on `xl/styles.xml` without using openpyxl. Every operational step provides ready-to-use XML snippets. + +--- + +## 1. When to Use This Path + +This document (FORMAT path) applies to the following two scenarios: + +**Scenario A — Dedicated Formatting of an Existing File** +The user provides an existing xlsx file and requests that financial modeling formatting standards be applied or unified. The starting point is to unpack the file, audit the existing `styles.xml`, then append missing styles and batch-update cell `s` attributes. No cell values or formulas are modified. + +**Scenario B — Applying Format Standards After CREATE/EDIT** +After completing data entry or formula writing, formatting is applied as the final step. At this point, `styles.xml` may come from the minimal_xlsx template (which pre-defines 13 style slots) or from a user file. In either case, follow the principle of "append only, never modify existing xf entries." + +**Not applicable**: Reading or analyzing file contents only (use the READ path); modifying formulas or data (use the EDIT path). + +--- + +## 2. Financial Format Semantic System + +### 2.1 Font Color = Cell Role (Color = Role) + +The primary convention of financial modeling: **font color encodes the cell's role, not decoration**. A reviewer can glance at colors to determine which cells are adjustable parameters and which are model-calculated results. This is an industry-wide convention (followed by investment banks, the Big Four, and corporate finance teams). + +| Role | Font Color | AARRGGBB | Use Case | +|------|-----------|----------|----------| +| Hard-coded input / assumption | Blue | `000000FF` | Growth rates, discount rates, tax rates, and other user-modifiable parameters | +| Formula / calculated result | Black | `00000000` | All cells containing a `` element | +| Same-workbook cross-sheet reference | Green | `00008000` | Cells whose formula starts with `SheetName!` | +| External file link | Red | `00FF0000` | Cells whose formula contains `[FileName.xlsx]` (flagged as fragile links) | +| Label / text | Black (default) | theme color | Row labels, category headings | +| Key assumption requiring review | Blue font + yellow fill | Font `000000FF` / Fill `00FFFF00` | Provisional values, parameters pending confirmation | + +**Decision tree**: +``` +Does the cell contain a element? + +-- Yes -> Does the formula start with [FileName]? + | +-- Yes -> Red (external link) + | +-- No -> Does the formula contain SheetName!? + | +-- Yes -> Green (cross-sheet reference) + | +-- No -> Black (same-sheet formula) + +-- No -> Is the value a user-adjustable parameter? + +-- Yes -> Blue (input/assumption) + +-- No -> Black default (label) +``` + +**Strictly prohibited**: Blue font + `` element coexisting (color role contradiction — must be corrected). + +### 2.2 Number Format Matrix + +| Data Type | formatCode | numFmtId | Display Example | Applicable Scenario | +|-----------|-----------|----------|-----------------|---------------------| +| Standard currency (whole dollars) | `$#,##0;($#,##0);"-"` | 164 | $1,234 / ($1,234) / - | P&L, balance sheet amount rows | +| Standard currency (with cents) | `$#,##0.00;($#,##0.00);"-"` | 169 | $1,234.56 / ($1,234.56) / - | Unit prices, detailed costs | +| Thousands (K) | `#,##0,"K"` | 171 | 1,234K | Simplified display for management reports | +| Millions (M) | `#,##0,,"M"` | 172 | 1M | Macro-level summary rows | +| Percentage (1 decimal) | `0.0%` | 165 | 12.5% | Growth rates, gross margins | +| Percentage (2 decimals) | `0.00%` | 170 | 12.50% | IRR, precise interest rates | +| Multiple / valuation multiplier | `0.0x` | 166 | 8.5x | EV/EBITDA, P/E | +| Integer (thousands separator) | `#,##0` | 167 | 12,345 | Employee count, unit quantities | +| Year | `0` | 1 (built-in, no declaration needed) | 2024 | Column header years, prevents 2,024 | +| Date | `m/d/yyyy` | 14 (built-in, no declaration needed) | 3/21/2026 | Timelines | +| General text | General | 0 (built-in, no declaration needed) | — | Label rows, cells with no format requirement | + +numFmtId 169–172 are custom formats that need to be appended beyond the 4 formats (164–167) pre-defined in the minimal_xlsx template. When appending, assign IDs according to the rules (see Section 3.4). + +**Built-in format IDs do not need to be declared in ``** (IDs 0–163 are built into Excel/LibreOffice; simply reference the numFmtId in ``): + +| numFmtId | formatCode | Description | +|----------|-----------|-------------| +| 0 | General | General format | +| 1 | `0` | Integer, no thousands separator (use this ID for years) | +| 3 | `#,##0` | Thousands-separated integer (no decimals) | +| 9 | `0%` | Percentage integer | +| 10 | `0.00%` | Percentage with two decimals | +| 14 | `m/d/yyyy` | Short date | + +### 2.3 Negative Number Display Standards + +Financial reports have two mainstream conventions for negative numbers — choose one and **maintain consistency** throughout the entire workbook: + +**Parenthetical style (investment banking standard, recommended for external deliverables)** + +``` +Positive: $1,234 Negative: ($1,234) Zero: - +formatCode: $#,##0;($#,##0);"-" +``` + +**Red minus sign style (suitable for internal operational analysis reports)** + +``` +Positive: $1,234 Negative: -$1,234 (red) +formatCode: $#,##0;[Red]-$#,##0;"-" +``` + +Rule: Once a style is determined, maintain it across the entire workbook. Do not mix two negative number display styles within the same workbook. + +### 2.4 Zero Value Display Standards + +In financial models, "0" and "no data" have different semantics and should be visually distinct: + +| Scenario | Recommended Display | formatCode Third Segment | +|----------|-------------------|--------------------------| +| Sparse matrix (most rows have zero-value periods) | Dash `-` | `"-"` | +| Quantity counts (zero itself is meaningful) | `0` | `0` or omit | +| Placeholder row (explicitly empty) | Leave blank | Do not write to cell | + +Four-segment format syntax: `positive format;negative format;zero value format;text format` + +Zero as dash: `$#,##0;($#,##0);"-"` +Zero preserved as 0: `#,##0;(#,##0);0` + +--- + +## 3. styles.xml Surgical Operations + +### 3.1 Auditing Existing Styles: Understanding the cellXfs Indirect Reference Chain + +A cell's `s` attribute points to a position index (0-based) in `cellXfs`, and each `` entry in `cellXfs` references its respective definition libraries through `fontId`, `fillId`, `borderId`, and `numFmtId`. + +Reference chain diagram: + +``` +Cell + | Look up cellXfs by 0-based index +cellXfs[6] -> numFmtId="164" fontId="2" fillId="0" borderId="0" + | | | | +numFmts fonts[2] fills[0] borders[0] +id=164 color=00000000 (no fill) (no border) +$#,##0... black +``` + +Audit steps: + +**Step 1**: Read `` and record all declared custom formats and their IDs: +```xml + + + + + + +``` +Record: current maximum custom numFmtId = 167, next available ID = 168. + +**Step 2**: Read `` and list each `` by 0-based index with its color and style: +``` +fontId=0 -> No explicit color (theme default black) +fontId=1 -> color rgb="000000FF" (blue, input role) +fontId=2 -> color rgb="00000000" (black, formula role) +fontId=3 -> color rgb="00008000" (green, cross-sheet reference role) +fontId=4 -> + color rgb="00000000" (bold black, header) +``` + +**Step 3**: Read `` and confirm that fills[0] and fills[1] are spec-mandated reserved entries (never delete): +``` +fillId=0 -> patternType="none" (spec-mandated) +fillId=1 -> patternType="gray125" (spec-mandated) +fillId=2 -> Yellow highlight (if present) +``` + +**Step 4**: Read `` and list each `` entry by 0-based index with its combination: +``` +index 0 -> numFmtId=0, fontId=0, fillId=0 -> Default style +index 1 -> numFmtId=0, fontId=1, fillId=0 -> Blue font general (input) +index 5 -> numFmtId=164, fontId=1, fillId=0 -> Blue font currency (currency input) +index 6 -> numFmtId=164, fontId=2, fillId=0 -> Black font currency (currency formula) +... +``` + +**Step 5**: Verify that all count attributes match the actual number of elements (count mismatches will cause Excel to refuse to open the file). + +### 3.2 Safely Appending New Styles (Golden Rule: Append Only, Never Modify Existing xf) + +**Never modify existing `` entries**. Modifications will affect all cells that already reference that index, breaking existing formatting. Only append new entries at the end. + +Complete atomic operation sequence for appending new styles (all 5 steps must be executed): + +**Step 1**: Determine if a new `` is needed + +Built-in formats (ID 0–163) skip this step. Custom formats are appended to the end of ``: +```xml + + + + + + + + + +``` + +**Step 2**: Determine if a new `` is needed + +Check whether the existing fonts already contain a matching color+style combination. If not, append to the end of ``: +```xml + + + ... + + + + + + + +``` +New fontId = the count value before appending (when original count=5, new fontId=5). + +**Step 3**: Determine if a new `` is needed + +If a new background color is needed, append to the end of `` (note: fills[0] and fills[1] must never be modified): +```xml + + + + + + + + + + + + + + + + + +``` + +**Step 4**: Append a new `` combination at the end of `` +```xml + + + ... + + + +``` +New style index = the count value before appending (when original count=13, new index=13). + +**Step 5**: Record the new style index; subsequently set the `s` attribute of corresponding cells in the sheet XML to this value. + +### 3.3 AARRGGBB Color Format Explanation + +OOXML's `rgb` attribute uses **8-digit hexadecimal AARRGGBB** format (not HTML's 6-digit RRGGBB): + +``` +AA RR GG BB +| | | | +Alpha Red Green Blue +``` + +- Alpha channel: `00` = fully opaque (normal use value); `FF` = fully transparent (invisible, never use this) +- Financial color standards always use `00` as the Alpha prefix + +| Color | AARRGGBB | Corresponding Role | +|-------|----------|-------------------| +| Blue (input) | `000000FF` | Hard-coded assumptions | +| Black (formula) | `00000000` | Calculated results | +| Green (cross-sheet reference) | `00008000` | Same-workbook cross-sheet | +| Red (external link) | `00FF0000` | References to other files | +| Yellow (review-required fill) | `00FFFF00` | Key assumption highlight | +| Light gray (projection period fill) | `00D3D3D3` | Distinguishing historical vs. forecast periods | +| White | `00FFFFFF` | Pure white fill | + +**Common mistake**: Mistakenly writing HTML format `#0000FF` as `FF0000FF` (Alpha=FF makes the color fully transparent and invisible). Correct format: `000000FF`. + +### 3.4 numFmtId Assignment Rules + +``` +ID 0-163 -> Excel/LibreOffice built-in formats, no declaration needed in , reference directly in +ID 164+ -> Custom formats, must be explicitly declared as elements in +``` + +Rules for assigning new IDs: +1. Read all `numFmtId` attribute values in the current `` +2. Take the maximum value + 1 as the next custom format ID +3. Do not reuse existing IDs; do not skip numbers + +The minimal_xlsx template pre-defines IDs: 164, 165, 166, 167. The next available ID is 168. + +--- + +## 4. Pre-defined Style Index Complete Reference Table (13 Slots) + +The following are the 13 style slots (cellXfs index 0–12) pre-defined in the minimal_xlsx template's `styles.xml`, which can be directly referenced in the cell `s` attribute in sheet XML: + +| Index | Semantic Role | Font Color | Fill | numFmtId | Format Display | Typical Use | +|-------|--------------|------------|------|----------|---------------|-------------| +| **0** | Default style | Theme black | None | 0 | General | Cells requiring no special formatting | +| **1** | Input / assumption (general) | Blue `000000FF` | None | 0 | General | Text-type assumptions, flags | +| **2** | Formula / calculated result (general) | Black `00000000` | None | 0 | General | Text concatenation formulas, non-numeric calculations | +| **3** | Cross-sheet reference (general) | Green `00008000` | None | 0 | General | Values pulled from cross-sheet (general format) | +| **4** | Header (bold) | Bold black | None | 0 | General | Row/column headings | +| **5** | Currency input | Blue `000000FF` | None | 164 | $1,234 / ($1,234) / - | Amount inputs in the assumptions area | +| **6** | Currency formula | Black `00000000` | None | 164 | $1,234 / ($1,234) / - | Amount calculations in the model area (revenue, EBITDA) | +| **7** | Percentage input | Blue `000000FF` | None | 165 | 12.5% | Rate inputs in the assumptions area (growth rate, gross margin assumptions) | +| **8** | Percentage formula | Black `00000000` | None | 165 | 12.5% | Rate calculations in the model area (actual gross margin) | +| **9** | Integer (comma) input | Blue `000000FF` | None | 167 | 12,345 | Quantity inputs in the assumptions area (employee count) | +| **10** | Integer (comma) formula | Black `00000000` | None | 167 | 12,345 | Quantity calculations in the model area | +| **11** | Year input | Blue `000000FF` | None | 1 | 2024 | Column header years (no thousands separator) | +| **12** | Key assumption highlight | Blue `000000FF` | Yellow `00FFFF00` | 0 | General | Key parameters pending review or confirmation | + +**Selection guide**: +- Determine "input" vs. "formula" -> Choose odd-numbered (input/blue) or even-numbered (formula/black) paired slots +- Determine data type -> Choose the corresponding currency (5/6) / percentage (7/8) / integer (9/10) / year (11) slot +- Cross-sheet reference needing number format -> Append a new green + number format combination (see Section 5.4) +- Parameter pending review -> index 12 + +--- + +## 5. Assumption Separation Principle: XML-Level Implementation + +### 5.1 Structural Design + +Assumption separation principle: **Input assumptions are centralized in a dedicated area (sheet or block); the model calculation area contains only formulas, no hard-coded values**. + +Recommended structure: +``` +Workbook sheet layout + sheet 1 "Assumptions" -> All blue-font cells (style 1/5/7/9/11/12) + sheet 2 "Model" -> All black or green-font cells (style 2/3/4/6/8/10) +``` + +Same-sheet zoning approach for simple models: +``` +Rows 1-5: [Assumptions block - blue font] +Row 6: [Empty row separator] +Rows 7+: [Model block - black/green font formulas referencing assumptions area] +``` + +### 5.2 Assumptions Area XML Example + +```xml + + + + + Model Assumptions + + + + + Revenue Growth Rate + 0.08 + + + + + Gross Margin + 0.65 + + + + + Base Revenue (Year 0) + 1000000 + + + + + Terminal Growth Rate + 0.03 + +``` + +### 5.3 Model Area XML Example (Referencing Assumptions Area) + +```xml + + + + + Metric + 2024 + 2025 + 2026 + + + + + Revenue + + + Assumptions!B4 + + B2*(1+Assumptions!B2) + C2*(1+Assumptions!B2) + + + + + Gross Profit + B2*Assumptions!B3 + C2*Assumptions!B3 + D2*Assumptions!B3 + + + + + Gross Margin % + B3/B2 + C3/C2 + D3/D2 + +``` + +### 5.4 Appending "Green + Number Format" Combinations + +Pre-defined index 3 is green font + general format. If a cross-sheet reference involves a currency amount, a green style with a number format must be appended: + +```xml + + + + +``` + +After appending, cross-sheet reference currency cells use `s="13"`. + +--- + +## 6. Complete Operational Workflow + +### 6.1 Workflow Overview + +``` +[Existing xlsx or file after CREATE/EDIT] + | + Step 1: Unpack (extract to temporary directory) + | + Step 2: Audit styles.xml (review existing styles, build index mapping table) + | + Step 3: Audit sheet XML (identify cells needing formatting and their semantic roles) + | + Step 4: Append missing styles (numFmt -> font -> fill -> xf, update counts) + | + Step 5: Batch-update the s attribute of each cell in the sheet XML + | + Step 6: XML validity + style reference integrity verification + | + Step 7: Pack (recompress as xlsx) +``` + +### 6.2 Step 1 — Unpack + +```bash +python3 SKILL_DIR/scripts/xlsx_unpack.py input.xlsx /tmp/xlsx_fmt/ +``` + +If the script is unavailable, unpack manually: +```bash +mkdir -p /tmp/xlsx_fmt && cp input.xlsx /tmp/xlsx_fmt/input.xlsx +cd /tmp/xlsx_fmt && unzip input.xlsx -d unpacked/ +``` + +### 6.3 Step 2 — Audit styles.xml + +Execute according to the method in Section 3.1. Quick check for minimal_xlsx template initial state: +- `` and `` -> Template initial state, all 13 pre-defined slots can be used directly +- Otherwise -> A complete review of the existing index mapping is required + +### 6.4 Step 3 — Audit Sheet XML, Build Formatting Plan + +Read `xl/worksheets/sheet*.xml` and evaluate each cell: +1. Does it contain a `` element (formula)? -> Requires black/green/red style +2. Is it a hard-coded numeric parameter? -> Requires blue style +3. Is the data type currency/percentage/integer/year? -> Select the corresponding number format slot +4. Is it a header? -> Bold style (index 4) + +Build a formatting mapping table: `{cell coordinate: target style index}` + +### 6.5 Step 4 — Append Styles + +Execute according to the atomic operation sequence in Section 3.2. Update the corresponding count attribute immediately after appending each component. + +### 6.6 Step 5 — Batch-Update Cell s Attributes + +```xml + +0.08 + + +0.08 +``` + +```xml + +B10*(1+Assumptions!B2) + + +B10*(1+Assumptions!B2) +``` + +For consecutive rows of the same type, row-level default styles can be used to reduce repetition: +```xml + + + Operating Income + B3-B4 + C3-C4 + +``` + +### 6.7 Step 6 — Verification + +```bash +# XML validity verification is handled automatically by xlsx_pack.py, no need to manually run xmllint +# The pack script validates styles.xml and sheet XML legality before packaging; it aborts and reports on errors + +# Style audit (optional, audit the entire unpacked directory after formatting is complete) +python3 SKILL_DIR/scripts/style_audit.py /tmp/xlsx_fmt/unpacked/ + +# Formula error static scan (must specify a single .xlsx file, does not accept directories) +# Pack first, then scan: +python3 SKILL_DIR/scripts/xlsx_pack.py /tmp/xlsx_fmt/unpacked/ /tmp/output.xlsx +python3 SKILL_DIR/scripts/formula_check.py /tmp/output.xlsx +``` + +Manual style reference integrity check: +```bash +# Find the maximum s attribute value in the sheet XML +grep -o 's="[0-9]*"' /tmp/xlsx_fmt/unpacked/xl/worksheets/sheet1.xml \ + | grep -o '[0-9]*' | sort -n | tail -1 + +# Compare with the cellXfs count attribute (max s value must be < count) +grep 'cellXfs count' /tmp/xlsx_fmt/unpacked/xl/styles.xml +``` + +### 6.8 Step 7 — Pack + +```bash +python3 SKILL_DIR/scripts/xlsx_pack.py /tmp/xlsx_fmt/unpacked/ output.xlsx +``` + +If the script is unavailable, pack manually: +```bash +cd /tmp/xlsx_fmt/unpacked/ +zip -r ../output.xlsx . -x "*.DS_Store" +``` + +--- + +## 7. Formatting Completeness Checklist + +Verify each item before delivery: + +### Color Role Consistency +- [ ] All numeric cells containing `` elements: fontId corresponds to black (formula) or green (cross-sheet reference) +- [ ] All hard-coded numeric values that are user-adjustable parameters: fontId corresponds to blue (input) +- [ ] Cross-sheet references (formula contains `SheetName!`): fontId corresponds to green +- [ ] External file references (formula contains `[FileName.xlsx]`): fontId corresponds to red +- [ ] No cell simultaneously contains a `` element and uses blue font (color role contradiction) + +### Number Format Correctness +- [ ] Year columns: numFmtId="1" (`0` format), displays as 2024 not 2,024 +- [ ] Currency rows: numFmtId="164" or variant, negative numbers display as ($1,234) not -$1,234 +- [ ] Percentage rows: values stored as decimals (0.08 = 8%), format numFmtId="165", displays as 8.0% +- [ ] Zero values: displayed as `-` in sparse matrices rather than `0` (formatCode third segment contains `"-"`) +- [ ] Multiple rows (EV/EBITDA, etc.): numFmtId="166" (`0.0x` format) +- [ ] Negative number display style is consistent throughout the entire workbook (parenthetical or red minus sign) + +### styles.xml Structural Integrity +- [ ] `` = actual number of `` elements +- [ ] `` = actual number of `` elements +- [ ] `` = actual number of `` elements (including spec-mandated fills[0] and fills[1]) +- [ ] `` = actual number of `` elements +- [ ] fills[0] is `patternType="none"`, fills[1] is `patternType="gray125"` (spec-mandated) +- [ ] All `` referenced fontId / fillId / borderId are within the valid range of their respective collections +- [ ] All cell `s` attribute values < `cellXfs count` (no out-of-bounds references) + +### Assumption Separation Verification +- [ ] No black-font numeric cells in the assumptions area/sheet (black numeric = formula, should not be in assumptions) +- [ ] No blue-font non-year numeric cells in the model area/sheet (blue numeric = hard-coded, should be in assumptions) +- [ ] Input parameters in the model area reference the assumptions area via formulas, not by directly copying values + +### Formula and Format Linkage +- [ ] All cells with `` elements have an explicit `s` attribute (must not use default style=0, whose font color is not explicitly black) +- [ ] SUM summary rows: style uses black font + corresponding number format (e.g., s="6" for currency summaries) +- [ ] Percentage formulas: values stored as decimals, format is `0.0%`; do not multiply values by 100 before applying percentage format + +### Visual Hierarchy +- [ ] Header rows (years/metric names): style=4 (bold black) +- [ ] Summary rows (Total/EBITDA/Net Income): bold + corresponding number format (append style if needed) +- [ ] Unit description rows (e.g., "$ thousands"): use style=0 or style=2 (blue not needed) + +--- + +## 8. Prohibited Actions (What You Must NOT Do) + +- **Do not modify existing `` entries**: This will batch-change the style of all cells referencing that index +- **Do not delete fills[0] and fills[1]**: Required by OOXML specification; deletion causes file corruption +- **Do not modify cell values or formulas**: The FORMAT path only changes styles, not content +- **Do not use openpyxl for formatting**: openpyxl rewrites the entire styles.xml on save, losing unsupported features +- **Do not apply global override styles**: Do not cover the entire workbook with a single style; assign precisely by semantic role +- **Do not write FF in the Alpha channel**: `rgb="FF0000FF"` makes the color fully transparent; the correct format is `rgb="000000FF"` + +--- + +## 9. Common Errors and Fixes + +### Error 1: Year displays as 2,024 + +Cause: The year cell's `s` attribute uses a format with thousands separator (e.g., numFmtId="3" or numFmtId="167"). + +```xml + +2024 + + +2024 +``` + +### Error 2: Percentage displays as 800% (value was multiplied by 100) + +Cause: 8% was stored as `8` instead of `0.08`. Excel's `%` format automatically multiplies the value by 100 for display. + +```xml + +8 + + +0.08 +``` + +### Error 3: File corruption after appending styles without updating count + +Cause: A `` or `` element was appended but the count attribute was not updated; Excel reads beyond bounds using the old count. + +Fix: Update the corresponding count immediately after appending each element: +```xml + + + ... + +``` + +### Error 4: Blue font + formula (color role contradiction) + +Cause: A formula cell mistakenly uses an input style (e.g., s="5" for currency input). + +```xml + +B5*1.08 + + +B5*1.08 +``` + +### Error 5: AARRGGBB color missing Alpha (only 6 digits) + +```xml + + + + + +``` + +### Error 6: Modifying existing xf (affects all cells referencing that index) + +Cause: Directly modifying attributes of the Nth `` in cellXfs, causing all cells with `s="N"` to be batch-changed. + +Fix: Keep existing entries unchanged, append a new entry at the end, and only change the `s` attribute of cells that need the new style to the new index: +```xml + + + + + + + + + + +``` + +--- + +## 10. Financial Model Structure Conventions + +### 10.1 Header Rows + +- Bold font (corresponds to style index 4 in this skill's template) +- Year columns: use number format `0` (numFmtId="1", no thousands separator) to prevent 2024 from displaying as 2,024 +- A unit description row may be added below headers: gray or italic text, e.g., "$ thousands" or "% of Revenue" + +### 10.2 Row Type Standards + +| Row Type | Style Recommendation | Example | +|----------|---------------------|---------| +| Category heading row | Bold, optionally with fill color | "Revenue" | +| Line item row | Normal style | "Product A", "Product B" | +| Subtotal row | Bold + top border | "Total Revenue" | +| Operating metric row | Normal style | "Gross Margin %" | +| Separator row | Empty row | (empty) | + +### 10.3 Multi-Year Model Column Layout + +``` +Col A: Label column (width 28, left-aligned text, s="4" for headers or s="0" for labels) +Col B: FY2022 Actual (width 12, year header s="11", data cells styled by semantic role) +Col C: FY2023 Actual +Col D: FY2024E (forecast period - can use light gray fill fillId=3 to differentiate) +Col E: FY2025E +Col F: FY2026E +``` + +### 10.4 Cross-Sheet Reference Patterns + +Complete XML example of parameters passing from assumptions sheet to model sheet: + +```xml + +0.08 + + + +Assumptions!B5 +``` + +--- + +## 11. Assumption Categories + +In the assumptions area (Assumptions sheet or assumptions block), organize assumptions in the following standard order for ease of review and maintenance: + +1. **Revenue assumptions**: Growth rates, pricing, sales volume +2. **Cost assumptions**: Gross margin, fixed/variable cost ratios +3. **Working capital**: DSO (Days Sales Outstanding), DPO (Days Payable Outstanding), inventory days +4. **Capital expenditures (CapEx)**: As a percentage of revenue or absolute amounts +5. **Financing assumptions**: Interest rates, debt repayment schedules +6. **Tax and other**: Effective tax rate, depreciation & amortization (D&A) + +--- + +## 12. Audit Trail Best Practices + +- Use `s="12"` (blue font + yellow fill highlight) to mark cells requiring review or pending changes, making them immediately visible to reviewers +- In sensitivity analysis rows or a separate Sensitivity tab, show the impact of +/-1% changes in key assumptions on results +- **Do not hide rows containing assumptions**: Assumption rows must be visible to reviewers; do not use the `hidden="1"` attribute +- Note a "Last Updated" date at the top of the assumptions area or in a dedicated cell, recording the last modification time of the model + +--- + +## 13. Pre-Delivery Checklist (Common Financial Model Checklist) + +Before outputting the final file, confirm each item: + +- [ ] Formula rows contain no hard-coded values (can use `formula_check.py` to scan the packaged `.xlsx` file) +- [ ] Year columns display as 2024 not 2,024 (numFmtId="1", format `0`) +- [ ] Negative numbers display as (1,234) not -1,234 (use parenthetical style for externally delivered financial reports) +- [ ] Zero values display as `-` in sparse rows rather than `0` (formatCode third segment is `"-"`) +- [ ] Growth rates and percentages are stored as decimals (0.08 = 8%), format is `0.0%` +- [ ] All cross-sheet reference cells use green font (style index 3 or an appended green + number format combination) +- [ ] Assumptions block and model block are clearly separated (different sheets or separated by empty rows within the same sheet) +- [ ] Summary rows use `SUM()` formulas, not manually hard-coded totals +- [ ] Balance verification: summary rows = sum of their respective line items (a check row can be added at the end of the model to verify) diff --git a/backend/app/skills_builtin/minimax-xlsx/references/ooxml-cheatsheet.md b/backend/app/skills_builtin/minimax-xlsx/references/ooxml-cheatsheet.md new file mode 100644 index 0000000..ed1393f --- /dev/null +++ b/backend/app/skills_builtin/minimax-xlsx/references/ooxml-cheatsheet.md @@ -0,0 +1,231 @@ +# OOXML SpreadsheetML Cheat Sheet + +Quick reference for XML manipulation of xlsx files. + +--- + +## Package Structure + +``` +my_file.xlsx (ZIP archive) +├── [Content_Types].xml ← declares MIME types for all files +├── _rels/ +│ └── .rels ← root relationship: points to xl/workbook.xml +└── xl/ + ├── workbook.xml ← sheet list, calc settings + ├── styles.xml ← ALL style definitions + ├── sharedStrings.xml ← ALL text strings (referenced by index) + ├── _rels/ + │ └── workbook.xml.rels ← maps r:id → worksheet/styles/sharedStrings files + ├── worksheets/ + │ ├── sheet1.xml ← Sheet 1 data + │ ├── sheet2.xml ← Sheet 2 data + │ └── ... + ├── charts/ ← chart XML (if any) + ├── pivotTables/ ← pivot table XML (if any) + └── theme/ + └── theme1.xml ← color/font theme +``` + +--- + +## Cell Reference Format + +``` +A1 → column A (1), row 1 +B5 → column B (2), row 5 +AA1 → column 27, row 1 +``` + +Column letter ↔ number conversion: +```python +def col_letter(n): # 1-based → letter + r = "" + while n > 0: + n, rem = divmod(n - 1, 26) + r = chr(65 + rem) + r + return r + +def col_number(s): # letter → 1-based + n = 0 + for c in s.upper(): + n = n * 26 + (ord(c) - 64) + return n +``` + +--- + +## Cell XML Reference + +### Data Types + +| Type | `t` attr | XML Example | Value | +|------|---------|-------------|-------| +| Number | omit | `1000` | 1000 | +| String (shared) | `s` | `0` | sharedStrings[0] | +| String (inline) | `inlineStr` | `Hi` | "Hi" | +| Boolean | `b` | `1` | TRUE | +| Error | `e` | `#REF!` | #REF! | +| Formula | omit | `SUM(B2:B3)` | computed | + +### Formula Types + +```xml + +SUM(B2:B3) + + +Assumptions!B5 +'Sheet With Spaces'!B5 + + +B2*C2 + + + +SORT(A1:A5) +``` + +--- + +## styles.xml Reference + +### Indirect Reference Chain + +``` +Cell s="3" + ↓ +cellXfs[3] → fontId="2", fillId="0", borderId="0", numFmtId="165" + ↓ ↓ ↓ ↓ ↓ +fonts[2] fills[0] borders[0] numFmts: id=165 +blue color no fill no border "0.0%" +``` + +### Adding a New Style (step-by-step) + +1. In ``: add ``, update `count` +2. In ``: add font entry, note its index +3. In ``: append ``, update `count` +4. New style index = old `cellXfs count` value (before incrementing) +5. Apply to cells: `...` + +### Color Format + +`AARRGGBB` — Alpha (always `00` for opaque) + Red + Green + Blue + +``` +000000FF → Blue +00000000 → Black +00008000 → Green (dark) +00FF0000 → Red +00FFFF00 → Yellow (for fills) +00FFFFFF → White +``` + +### Built-in numFmtIds (no declaration needed) + +| ID | Format | Display | +|----|--------|---------| +| 0 | General | as-is | +| 1 | 0 | 2024 (use for years!) | +| 2 | 0.00 | 1000.00 | +| 3 | #,##0 | 1,000 | +| 4 | #,##0.00 | 1,000.00 | +| 9 | 0% | 15% | +| 10 | 0.00% | 15.25% | +| 14 | m/d/yyyy | 3/21/2026 | + +--- + +## sharedStrings.xml Reference + +```xml + + Revenue + Cost + Margin + +``` + +Text with leading/trailing spaces: +```xml + indented +``` + +Special characters: +```xml +R&D Expenses +``` + +--- + +## workbook.xml / .rels Sync + +Every `` in workbook.xml needs a matching `` in workbook.xml.rels: + +```xml + + + + + + +``` + +And a matching `` in `[Content_Types].xml`: +```xml + +``` + +--- + +## Column / Row Dimensions + +```xml + + + + + + + + + ... + +``` + +--- + +## Freeze Panes + +Inside ``: +```xml + + + + + + + + +``` + +--- + +## 7 Excel Error Types (All Must Be Absent at Delivery) + +| Error | Meaning | Detect in XML | +|-------|---------|---------------| +| `#REF!` | Invalid cell reference | `#REF!` | +| `#DIV/0!` | Divide by zero | `#DIV/0!` | +| `#VALUE!` | Wrong data type | `#VALUE!` | +| `#NAME?` | Unknown function/name | `#NAME?` | +| `#NULL!` | Empty intersection | `#NULL!` | +| `#NUM!` | Number out of range | `#NUM!` | +| `#N/A` | Value not found | `#N/A` | diff --git a/backend/app/skills_builtin/minimax-xlsx/references/read-analyze.md b/backend/app/skills_builtin/minimax-xlsx/references/read-analyze.md new file mode 100644 index 0000000..15337df --- /dev/null +++ b/backend/app/skills_builtin/minimax-xlsx/references/read-analyze.md @@ -0,0 +1,97 @@ +# Data Reading & Analysis Guide + +> Reference for the READ path. Use `xlsx_reader.py` for structure discovery and data quality auditing, +> then pandas for custom analysis. **Never modify the source file.** + +--- + +## When to Use This Path + +The user asks to read, analyze, view, summarize, extract, or answer questions about an Excel/CSV file's contents, +without requiring file modification. If modification is needed, hand off to `edit.md`. + +--- + +## Workflow + +### Step 1 — Structure Discovery + +Run `xlsx_reader.py` first. It handles format detection, encoding fallback, structure exploration, and data quality audit: + +```bash +python3 SKILL_DIR/scripts/xlsx_reader.py input.xlsx # full report +python3 SKILL_DIR/scripts/xlsx_reader.py input.xlsx --sheet Sales # single sheet +python3 SKILL_DIR/scripts/xlsx_reader.py input.xlsx --quality # quality audit only +python3 SKILL_DIR/scripts/xlsx_reader.py input.xlsx --json # machine-readable +``` + +Supported formats: `.xlsx`, `.xlsm`, `.csv`, `.tsv`. The script tries multiple encodings for CSV (utf-8-sig, gbk, utf-8, latin-1). + +### Step 2 — Custom Analysis with pandas + +Load data and perform the analysis the user requests: + +```python +import pandas as pd +df = pd.read_excel("input.xlsx", sheet_name=None) # dict of all sheets +# For CSV: pd.read_csv("input.csv") +``` + +**Header handling** (when the default `header=0` doesn't work): + +| Situation | Code | +|-----------|------| +| Header on row 3 | `pd.read_excel(path, header=2)` | +| Multi-level merged header | `pd.read_excel(path, header=[0, 1])` | +| No header | `pd.read_excel(path, header=None)` | + +**Analysis quick reference:** + +| Scenario | Pattern | +|----------|---------| +| Descriptive stats | `df.describe()` or `df['Col'].agg(['sum', 'mean', 'min', 'max'])` | +| Group aggregation | `df.groupby('Region')['Revenue'].agg(Total='sum', Avg='mean')` | +| Top N | `df.groupby('Region')['Revenue'].sum().sort_values(ascending=False).head(5)` | +| Pivot table | `df.pivot_table(values='Revenue', index='Region', columns='Quarter', aggfunc='sum', margins=True)` | +| Time series | `df.set_index(pd.to_datetime(df['Date'])).resample('ME')['Revenue'].sum()` | +| Cross-sheet merge | `pd.merge(sales, customers, on='CustomerID', how='left', validate='m:1')` | +| Stack sheets | `pd.concat([df.assign(Source=name) for name, df in sheets.items()], ignore_index=True)` | +| Large files (>50MB) | `pd.read_excel(path, usecols=['Date', 'Revenue'])` or `pd.read_csv(path, chunksize=10000)` | + +### Step 3 — Output + +If the user specifies an output file path, write results to it (highest priority). Format the report as: + +``` +## Analysis Report: {filename} +### File Overview — format, sheets, row counts +### Data Quality — nulls, duplicates, mixed types (or "no issues") +### Key Findings — direct answer to the user's question +### Additional Notes — formula NaN, encoding issues, caveats +``` + +**Numeric display**: monetary `1,234,567.89`, percentage `12.3%`, multiples `8.5x`, counts as integers. + +--- + +## Common Pitfalls + +| Pitfall | Cause | Fix | +|---------|-------|-----| +| Formula cells read as NaN | `` cache empty in freshly generated files | Inform user; suggest opening in Excel and re-saving; or use `libreoffice_recalc.py` | +| CSV encoding errors | Chinese Windows exports use GBK | `xlsx_reader.py` auto-tries multiple encodings; manually specify if all fail | +| Mixed types in column | Column has both numbers and text (e.g., "N/A") | `pd.to_numeric(df['Col'], errors='coerce')` — report unconvertible rows | +| Year shows as 2,024 | Thousands separator format applied to year | `df['Year'].astype(int).astype(str)` | +| Multi-level headers | Two-row header merged | `pd.read_excel(path, header=[0, 1])`, then flatten with `' - '.join()` | +| Row number mismatch | pandas 0-indexed vs Excel 1-indexed | `excel_row = pandas_index + 2` (+1 for 1-index, +1 for header) | + +**Critical**: Never open with `data_only=True` then `save()` — this permanently destroys all formulas. + +--- + +## Prohibitions + +- Never modify the source file (no `save()`, no XML edits) +- Never report formula NaN as "data is zero" — explain it's a formula cache issue +- Never report pandas indices as Excel row numbers +- Never make speculative conclusions unsupported by the data diff --git a/backend/app/skills_builtin/minimax-xlsx/references/validate.md b/backend/app/skills_builtin/minimax-xlsx/references/validate.md new file mode 100644 index 0000000..c02e261 --- /dev/null +++ b/backend/app/skills_builtin/minimax-xlsx/references/validate.md @@ -0,0 +1,772 @@ +# Formula Validation & Recalculation Guide + +Ensure every formula in an xlsx file is provably correct before delivery. A file that opens without visible errors is not a passing file — only a file that has cleared both validation tiers is a passing file. + +--- + +## Foundational Rules + +- **Never declare PASS without running `formula_check.py` first.** Visual inspection of a spreadsheet is not validation. +- **Tier 1 (static) is mandatory in every scenario.** Tier 2 (dynamic) is mandatory when LibreOffice is available. If it is unavailable, you must state this explicitly in the report — you may not silently skip it. +- **Never use openpyxl with `data_only=True` to check formula values.** Opening and saving a workbook in `data_only=True` mode permanently replaces all formulas with their last cached values. Formulas cannot be recovered afterward. +- **Auto-fix only deterministic errors.** Any fix that requires understanding business logic must be flagged for human review. + +--- + +## Two-Tier Validation Architecture + +``` +Tier 1 — Static Validation (XML scan, no external tools) + │ + ├── Detect: all 7 Excel error types already cached in elements + ├── Detect: cross-sheet references pointing to nonexistent sheets + ├── Detect: formula cells with t="e" attribute (error type marker) + └── Tool: formula_check.py + manual XML inspection + │ + ▼ (if LibreOffice is present) +Tier 2 — Dynamic Validation (LibreOffice headless recalculation) + │ + ├── Executes all formulas via the LibreOffice Calc engine + ├── Populates cache values with real computed results + ├── Exposes runtime errors invisible before recalculation + └── Follow-up: re-run Tier 1 on the recalculated file +``` + +**Why two tiers?** + +openpyxl and all Python xlsx libraries write formula strings (e.g. `=SUM(B2:B9)`) into `` elements but do not evaluate them. A freshly generated file has empty `` cache elements for every formula cell. This means: + +- Tier 1 can only catch errors that are already encoded in the XML — either as `t="e"` cells or as structurally broken cross-sheet references. +- Tier 2 uses LibreOffice as the actual calculation engine, runs every formula, fills `` with real results, and surfaces runtime errors (`#DIV/0!`, `#N/A`, etc.) that can only appear after computation. + +Neither tier alone is sufficient. Together they cover the full correctability surface. + +--- + +## Tier 1 — Static Validation + +Static validation requires no external tools. It works directly on the ZIP/XML structure of the xlsx file. + +### Step 1: Run formula_check.py + +**Standard (human-readable) output:** + +```bash +python3 SKILL_DIR/scripts/formula_check.py /path/to/file.xlsx +``` + +**JSON output (for programmatic processing):** + +```bash +python3 SKILL_DIR/scripts/formula_check.py /path/to/file.xlsx --json +``` + +**Single-sheet mode (faster for targeted checks):** + +```bash +python3 SKILL_DIR/scripts/formula_check.py /path/to/file.xlsx --sheet Summary +``` + +**Summary mode (counts only, no per-cell detail):** + +```bash +python3 SKILL_DIR/scripts/formula_check.py /path/to/file.xlsx --summary +``` + +Exit codes: +- `0` — no hard errors (PASS or PASS with heuristic warnings) +- `1` — hard errors detected, or file cannot be opened (FAIL) + +#### What formula_check.py examines + +The script opens the xlsx as a ZIP archive without using any Excel library. It reads `xl/workbook.xml` to enumerate sheet names and named ranges, reads `xl/_rels/workbook.xml.rels` to map each sheet to its XML file, then iterates every `` element in every worksheet. + +It performs five checks: + +1. **Error-value detection**: If the cell has `t="e"`, its `` element contains an Excel error string. The cell is recorded with its sheet name, cell reference (e.g. `C5`), the error value, and the formula text if present. + +2. **Broken cross-sheet reference detection**: If the cell has an `` element, the script extracts all sheet names referenced in the formula (both `SheetName!` and `'Sheet Name'!` syntax). Each name is compared against the list of sheets in `workbook.xml`. A mismatch is a broken reference. + +3. **Unknown named-range detection (heuristic)**: Identifiers in formulas that are not function names, not cell references, and not found in `workbook.xml`'s `` are flagged as `unknown_name_ref` warnings. This is a heuristic — false positives are possible; always verify manually. + +4. **Shared formula integrity**: Shared formula consumer cells (those with only ``) are skipped for formula counting and cross-ref checks because they inherit the primary cell's formula. Only the primary cell (with `ref="..."` attribute and formula text) is checked and counted. + +5. **Malformed error cells**: Cells with `t="e"` but no `` child element are flagged as structural XML issues. + +Hard errors (exit code 1): `error_value`, `broken_sheet_ref`, `malformed_error_cell`, `file_error` +Soft warnings (exit code 0): `unknown_name_ref` — must be verified manually but do not block delivery alone + +#### Reading formula_check.py human-readable output + +A clean file looks like this: + +``` +File : /tmp/budget_2024.xlsx +Sheets : Summary, Q1, Q2, Q3, Q4, Assumptions +Formulas checked : 312 distinct formula cells +Shared formula ranges : 4 ranges +Errors found : 0 + +PASS — No formula errors detected +``` + +A file with errors looks like this: + +``` +File : /tmp/budget_2024.xlsx +Sheets : Summary, Q1, Q2, Q3, Q4, Assumptions +Formulas checked : 312 distinct formula cells +Shared formula ranges : 4 ranges +Errors found : 4 + +── Error Details ── + [FAIL] [Summary!C12] contains #REF! (formula: Q1!A0/Q1!A1) + [FAIL] [Summary!D15] references missing sheet 'Q5' + Formula: Q5!D15 + Valid sheets: ['Assumptions', 'Q1', 'Q2', 'Q3', 'Q4', 'Summary'] + [FAIL] [Q1!F8] contains #DIV/0! + [WARN] [Q2!B10] uses unknown name 'GrowthAssumptions' (heuristic — verify manually) + Formula: SUM(GrowthAssumptions) + Defined names: ['RevenueRange', 'CostRange'] + +FAIL — 3 error(s) must be fixed before delivery +WARN — 1 heuristic warning(s) require manual review +``` + +Interpretation of each line: +- `[FAIL] [Summary!C12] contains #REF! (formula: Q1!A0/Q1!A1)` — The cell has `t="e"` and `#REF!`. The formula references row 0, which does not exist in Excel's 1-based system. This is an off-by-one error in a generated reference. +- `[FAIL] [Summary!D15] references missing sheet 'Q5'` — The formula contains `Q5!D15`, but no sheet named `Q5` exists in the workbook. The valid sheet list is provided for comparison. +- `[FAIL] [Q1!F8] contains #DIV/0!` — This cell's `` is already an error value (the file was previously recalculated). The formula divided by zero. +- `[WARN] [Q2!B10] uses unknown name 'GrowthAssumptions'` — The identifier `GrowthAssumptions` appears in the formula but is not in ``. This may be a typo or a name that was accidentally omitted. It is a heuristic warning — verify manually. The warning alone does not block delivery. + +#### Reading formula_check.py JSON output + +```json +{ + "file": "/tmp/budget_2024.xlsx", + "sheets_checked": ["Summary", "Q1", "Q2", "Q3", "Q4", "Assumptions"], + "formula_count": 312, + "shared_formula_ranges": 4, + "error_count": 4, + "errors": [ + { + "type": "error_value", + "error": "#REF!", + "sheet": "Summary", + "cell": "C12", + "formula": "Q1!A0/Q1!A1" + }, + { + "type": "broken_sheet_ref", + "sheet": "Summary", + "cell": "D15", + "formula": "Q5!D15", + "missing_sheet": "Q5", + "valid_sheets": ["Assumptions", "Q1", "Q2", "Q3", "Q4", "Summary"] + }, + { + "type": "error_value", + "error": "#DIV/0!", + "sheet": "Q1", + "cell": "F8", + "formula": null + }, + { + "type": "unknown_name_ref", + "sheet": "Q2", + "cell": "B10", + "formula": "SUM(GrowthAssumptions)", + "unknown_name": "GrowthAssumptions", + "defined_names": ["RevenueRange", "CostRange"], + "note": "Heuristic check — verify manually if this is a false positive" + } + ] +} +``` + +Field reference: + +| Field | Meaning | +|-------|---------| +| `type: "error_value"` | Cell has `t="e"` — an Excel error is stored in the `` element | +| `type: "broken_sheet_ref"` | Formula references a sheet name not present in workbook.xml | +| `type: "unknown_name_ref"` | Formula references an identifier not in `` (heuristic, soft warning) | +| `type: "malformed_error_cell"` | Cell has `t="e"` but no `` child — structural XML problem | +| `type: "file_error"` | The file could not be opened (bad ZIP, not found, etc.) | +| `sheet` | The sheet where the error was found | +| `cell` | Cell reference in A1 notation | +| `formula` | The full formula text from the `` element (null if not present) | +| `error` | The error string from `` (for `error_value` type) | +| `missing_sheet` | The sheet name extracted from the formula that does not exist | +| `valid_sheets` | All sheet names actually present in workbook.xml | +| `unknown_name` | The identifier that was not found in `` | +| `defined_names` | All named ranges actually present in workbook.xml | +| `shared_formula_ranges` | Count of shared formula definitions (top-level `` elements) | + +### Step 2: Manual XML inspection + +When formula_check.py reports errors, unpack the file to inspect the raw XML: + +```bash +python3 SKILL_DIR/scripts/xlsx_unpack.py /path/to/file.xlsx /tmp/xlsx_inspect/ +``` + +Navigate to the worksheet file for the reported sheet. The sheet-to-file mapping is in `xl/_rels/workbook.xml.rels`. For example, if `rId1` maps to `worksheets/sheet1.xml`, then sheet1.xml is the file for the sheet with `r:id="rId1"` in `xl/workbook.xml`. + +For each reported error cell, locate the `` element and examine: + +**For `error_value` errors:** +```xml + + + Q1!C10/Q1!C11 + #DIV/0! + +``` + +Ask: +- Is the `` formula syntactically correct? +- Does the cell reference in the formula point to a row/column that exists? +- If it is a division, is it possible the denominator cell is empty or zero? + +**For `broken_sheet_ref` errors:** + +Check `xl/workbook.xml` for the actual sheet list: + +```xml + + + + + +``` + +Sheet names are case-sensitive. `q1` and `Q1` are different sheets. Compare the name in the formula exactly against the names here. + +### Step 3: Cross-sheet reference audit (multi-sheet workbooks) + +For workbooks with 3 or more sheets, run a broader cross-reference audit after unpacking: + +```bash +# Extract all formulas containing cross-sheet references +grep -h "" /tmp/xlsx_inspect/xl/worksheets/*.xml | grep "!" + +# List all actual sheet names from workbook.xml +grep -o 'name="[^"]*"' /tmp/xlsx_inspect/xl/workbook.xml | grep -v sheetId +``` + +Every sheet name appearing in formulas (in the form `SheetName!` or `'Sheet Name'!`) must appear in the workbook sheet list. If any do not match, that is a broken reference even if formula_check.py did not catch it (which can happen with shared formulas where only the primary cell is examined). + +To check shared formulas specifically, look for `` elements: + +```xml + +Q1!B2*C2 + + + +``` + +formula_check.py reads the formula text from the primary cell (`D2` above). The referenced sheet `Q1` in that formula applies to the entire range `D2:D100`. If the sheet is broken, all 99 rows are broken even though they appear as empty `` elements. + +--- + +## Tier 2 — Dynamic Validation (LibreOffice Headless) + +### Check LibreOffice availability + +```bash +# Check macOS (typical install location) +which soffice +/Applications/LibreOffice.app/Contents/MacOS/soffice --version + +# Check Linux +which libreoffice || which soffice +libreoffice --version +``` + +If neither command returns a path, LibreOffice is not installed. Record "Tier 2: SKIPPED — LibreOffice not available" in the report and proceed to delivery with Tier 1 results only. + +### Install LibreOffice (if permitted in the environment) + +macOS: +```bash +brew install --cask libreoffice +``` + +Ubuntu/Debian: +```bash +sudo apt-get install -y libreoffice +``` + +### Run headless recalculation + +Use the dedicated recalculation script. It handles binary discovery across macOS and Linux, works from a temporary copy of the input (preserving the original), and provides structured output and exit codes compatible with the validation pipeline. + +```bash +# Check LibreOffice availability first +python3 SKILL_DIR/scripts/libreoffice_recalc.py --check + +# Run recalculation (default timeout: 60s) +python3 SKILL_DIR/scripts/libreoffice_recalc.py /path/to/input.xlsx /tmp/recalculated.xlsx + +# For large or complex files, extend the timeout +python3 SKILL_DIR/scripts/libreoffice_recalc.py /path/to/input.xlsx /tmp/recalculated.xlsx --timeout 120 +``` + +Exit codes from `libreoffice_recalc.py`: +- `0` — recalculation succeeded, output file written +- `2` — LibreOffice not found (note as SKIPPED in report; not a hard failure) +- `1` — LibreOffice found but failed (timeout, crash, malformed file) + +**What the script does internally:** + +LibreOffice's `--convert-to xlsx` command opens the file using the full Calc engine with the `--infilter="Calc MS Excel 2007 XML"` filter, executes every formula, writes computed values into the `` cache elements, and saves the output. This is the closest server-side equivalent of "open in Excel and press Save." The script also passes `--norestore` to prevent LibreOffice from attempting to restore previous sessions, which can cause hangs in automated environments. + +**If LibreOffice is not installed:** + +macOS: +```bash +brew install --cask libreoffice +``` + +Ubuntu/Debian: +```bash +sudo apt-get install -y libreoffice +``` + +**If the script times out (libreoffice_recalc.py exits with code 1 and "timed out" message):** + +Record "Tier 2: TIMEOUT — LibreOffice did not complete within Ns" in the report. Do not retry in a loop. Investigate whether the file has circular references or extremely large data ranges. + +### Re-run Tier 1 after recalculation + +After LibreOffice recalculation, the `` elements contain real computed values. Errors that were invisible before (because `` was empty in a freshly generated file) now appear as `t="e"` cells with actual error strings. + +```bash +python3 SKILL_DIR/scripts/formula_check.py /tmp/recalculated.xlsx +``` + +This second Tier 1 pass is the definitive runtime error check. Any errors it finds are real calculation failures that must be fixed. + +--- + +## All 7 Error Types — Causes and Fix Strategies + +### #REF! — Invalid Cell Reference + +**What it means:** The formula references a cell, range, or sheet that no longer exists or never existed. + +**Common causes in generated files:** +- Off-by-one error in row/column calculation (e.g., referencing row 0 which does not exist in Excel's 1-based system) +- Column letter computed incorrectly (e.g., column 64 maps to `BL`, not `BK`) +- Formula references a sheet that was never created or was renamed + +**XML signature:** +```xml + + Sheet2!A0 + #REF! + +``` + +**Fix — correct the reference:** +```xml + + Sheet2!A1 + + +``` + +Note: remove `t="e"` and clear `` after correcting the formula. The error type marker belongs to the cached state, not the formula. + +**Auto-fixable?** Only if the correct target can be determined with certainty from the surrounding context. Otherwise flag for human review. + +--- + +### #DIV/0! — Division by Zero + +**What it means:** The formula divides by a value that is zero or an empty cell (empty cells evaluate to 0 in arithmetic context). + +**Common causes in generated files:** +- Percentage change formula `=(B2-B1)/B1` where `B1` is empty or zero +- Rate formula `=Value/Total` where the total row hasn't been populated yet + +**XML signature:** +```xml + + B8/B7 + #DIV/0! + +``` + +**Fix — wrap with IFERROR:** +```xml + + IFERROR(B8/B7,0) + + +``` + +Alternative — explicit zero check: +```xml + + IF(B7=0,0,B8/B7) + + +``` + +**Auto-fixable?** Yes. Wrapping with `IFERROR(...,0)` is safe for most financial formulas. If the business expectation is that the result should display as blank rather than zero, use `IFERROR(...,"")` instead. + +--- + +### #VALUE! — Wrong Data Type + +**What it means:** The formula attempts an arithmetic or logical operation on a value of the wrong type (e.g., adding a text string to a number). + +**Common causes in generated files:** +- A cell intended to hold a number was written as a string type (`t="s"` or `t="inlineStr"`) instead of a numeric type +- A formula references a cell containing text (e.g., a unit label like "thousands") and treats it as a number + +**XML signature:** +```xml + + E3+D3 + #VALUE! + +``` + +**Fix — check source cells for incorrect type:** + +If `D3` was incorrectly written as a string: +```xml + +1000 + + +1000 +``` + +Alternatively, wrap the formula with `VALUE()` conversion: +```xml + + VALUE(E3)+VALUE(D3) + + +``` + +**Auto-fixable?** Partially. If the source cell type is visibly wrong (a number stored as string), fix the type. If the cause is ambiguous (the cell is supposed to contain text), flag for human review. + +--- + +### #NAME? — Unrecognized Name + +**What it means:** The formula contains an identifier that Excel does not recognize — either a misspelled function name, an undefined named range, or a function that is not available in the target Excel version. + +**Common causes in generated files:** +- LLM writes a function name with a typo: `SUMIF` written as `SUMIFS` when only 3 arguments are provided, or `XLOOKUP` used in a context targeting Excel 2010 +- Named range referenced in formula does not exist in `xl/workbook.xml` + +**XML signature:** +```xml + + SUMSQ(A2:A10) + #NAME? + +``` + +**Fix — verify function name and named ranges:** + +Check named ranges in `xl/workbook.xml`: +```xml + + Sheet1!$B$2:$B$13 + +``` + +If the formula references `RevenuRange` (typo), correct it to `RevenueRange`: +```xml + + SUM(RevenueRange) + + +``` + +**Auto-fixable?** Only if the correct name is unambiguous (e.g., a single close match exists). Otherwise flag for human review — function name fixes require understanding the intended calculation. + +--- + +### #N/A — Value Not Available + +**What it means:** A lookup function (VLOOKUP, HLOOKUP, MATCH, INDEX/MATCH, XLOOKUP) searched for a value that does not exist in the lookup table. + +**Common causes in generated files:** +- Lookup key exists in the formula but the lookup table is empty or not yet populated +- Key format mismatch (text "2024" vs numeric 2024) + +**XML signature:** +```xml + + VLOOKUP(F5,Assumptions!$A$2:$B$20,2,0) + #N/A + +``` + +**Fix — wrap with IFERROR for missing-match tolerance:** +```xml + + IFERROR(VLOOKUP(F5,Assumptions!$A$2:$B$20,2,0),0) + + +``` + +**Auto-fixable?** Adding `IFERROR` is safe if a zero default is acceptable. If the lookup failure indicates a data integrity problem (the key should always be present), do not auto-fix — flag for human review. + +--- + +### #NULL! — Empty Intersection + +**What it means:** The space operator (which computes the intersection of two ranges) was applied to two ranges that do not intersect. + +**Common causes in generated files:** +- Accidental space between two range references: `=SUM(A1:A5 C1:C5)` instead of `=SUM(A1:A5,C1:C5)` +- Rarely seen in typical financial models; usually indicates a formula generation error + +**XML signature:** +```xml + + SUM(A1:A5 C1:C5) + #NULL! + +``` + +**Fix — replace space with comma (union) or colon (range):** +```xml + + + SUM(A1:A5,C1:C5) + + +``` + +**Auto-fixable?** Yes. The space operator is almost never intentional in generated formulas. Replacing with a comma is safe. + +--- + +### #NUM! — Numeric Error + +**What it means:** A formula produced a number that Excel cannot represent (overflow, underflow) or a mathematical operation that has no real-number result (square root of negative, LOG of zero or negative). + +**Common causes in generated files:** +- IRR or NPV formula where the cash flow series has no convergent solution +- `SQRT()` applied to a cell that can be negative +- Very large exponentiation + +**XML signature:** +```xml + + IRR(B5:B15) + #NUM! + +``` + +**Fix — add a conditional guard:** +```xml + + IFERROR(IRR(B5:B15),"") + + +``` + +For SQRT: +```xml + + IF(A5>=0,SQRT(A5),"") + + +``` + +**Auto-fixable?** Partially. Wrapping with `IFERROR` suppresses the error display but does not fix the underlying calculation issue. Flag the cell for human review even after applying the IFERROR wrapper. + +--- + +## Auto-Fix vs. Human Review Decision Matrix + +| Error Type | Auto-Fix Safe? | Condition | Action | +|------------|---------------|-----------|--------| +| `#DIV/0!` | Yes | Always | Wrap with `IFERROR(formula,0)` | +| `#NULL!` | Yes | Always | Replace space operator with comma | +| `#REF!` | Yes | Only if correct target is unambiguous from context | Correct reference; otherwise flag | +| `#NAME?` | Yes | Only if typo has exactly one plausible correction | Fix name; otherwise flag | +| `#N/A` | Conditional | If a zero/blank default is business-acceptable | Add IFERROR wrapper; document assumption | +| `#VALUE!` | Conditional | Only if source cell type is clearly wrong | Fix type; otherwise flag | +| `#NUM!` | No | Always | Add IFERROR to suppress display, then flag | +| Broken sheet ref | Yes | Only if renamed sheet can be identified from workbook.xml | Correct name | +| Business logic errors | Never | Any case | Human review only | + +**What counts as a business logic error (never auto-fix):** +- A formula that produces a wrong number but no Excel error (e.g., `=SUM(B2:B8)` when the intent was `=SUM(B2:B9)`) +- A formula where the IFERROR default value is meaningful (e.g., whether to use 0, blank, or a prior-period value) +- Any formula where fixing the error requires knowing what the formula was supposed to calculate + +--- + +## Delivery Standard — Validation Report + +Every validation task must produce a structured report. This report is the deliverable, regardless of whether errors were found. + +### Required report format + +```markdown +## Formula Validation Report + +**File**: /path/to/filename.xlsx +**Date**: YYYY-MM-DD +**Sheets checked**: Sheet1, Sheet2, Sheet3 +**Total formulas scanned**: N + +--- + +### Tier 1 — Static Validation + +**Status**: PASS / FAIL +**Tool**: formula_check.py (direct XML scan) + +| Sheet | Cell | Error Type | Detail | Fix Applied | +|-------|------|-----------|--------|-------------| +| Summary | C12 | #REF! | Formula: Q1!A0 | Corrected to Q1!A1 | +| Summary | D15 | broken_sheet_ref | References missing sheet 'Q5' | Renamed to Q4 | + +_(If no errors: "No errors detected.")_ + +--- + +### Tier 2 — Dynamic Validation + +**Status**: PASS / FAIL / SKIPPED +**Tool**: LibreOffice headless (version X.Y.Z) / Not available + +_(If SKIPPED: state the reason — LibreOffice not installed, timeout, etc.)_ + +| Sheet | Cell | Error Type | Detail | Fix Applied | +|-------|------|-----------|--------|-------------| +| Q1 | F8 | #DIV/0! | Formula: C8/C7 | Wrapped with IFERROR | + +_(If no errors: "No runtime errors detected after recalculation.")_ + +--- + +### Summary + +- **Total errors found**: N +- **Auto-fixed**: N (list types) +- **Flagged for human review**: N (list cells and reason) +- **Final status**: PASS (ready for delivery) / FAIL (blocked) + +### Human Review Required + +| Cell | Error | Reason Auto-Fix Not Applied | +|------|-------|----------------------------| +| Q2!B15 | #NUM! | IRR formula — business must confirm cash flow inputs | +``` + +### Minimum required fields + +The report is invalid (and delivery is blocked) if any of these are missing: +- File path and date +- Which sheets were checked +- Total formula count +- Tier 1 status with explicit PASS/FAIL +- Tier 2 status with explicit PASS/FAIL/SKIPPED and reason if SKIPPED +- For every error: sheet, cell, error type, and disposition (fixed or flagged) +- Final delivery status + +--- + +## Common Scenarios + +### Scenario 1: Validate immediately after creating a new file + +When `create.md` workflow produces a new xlsx, run validation before any delivery response. + +```bash +# Step 1: Static check on the freshly written file +python3 SKILL_DIR/scripts/formula_check.py /path/to/output.xlsx + +# Step 2: Dynamic check (if LibreOffice available) +python3 SKILL_DIR/scripts/libreoffice_recalc.py /path/to/output.xlsx /tmp/recalculated.xlsx +python3 SKILL_DIR/scripts/formula_check.py /tmp/recalculated.xlsx +``` + +Expected behavior on a freshly created file: Tier 1 will find zero `error_value` errors (because `` elements are empty, not error-valued). It will find any broken cross-sheet references if sheet names were misspelled. Tier 2 will populate `` and reveal runtime errors like `#DIV/0!`. + +If Tier 2 reveals errors, fix them in the source XML (not the recalculated copy), repack, and re-run both tiers. + +### Scenario 2: Validate after editing an existing file + +When `edit.md` workflow modifies an existing xlsx, validate only the affected sheets if the edit was surgical. If the edit touched shared formulas or cross-sheet references, validate all sheets. + +```bash +# Targeted static check — look at specific sheet +# (formula_check.py checks all sheets; examine only the relevant section of output) +python3 SKILL_DIR/scripts/formula_check.py /path/to/edited.xlsx --json \ + | python3 -c " +import json, sys +r = json.load(sys.stdin) +for e in r['errors']: + if e.get('sheet') in ['Summary', 'Q1']: + print(e) +" +``` + +Always run Tier 2 after edits that modify formulas, even if Tier 1 passes. Edits to data ranges can cause previously-valid formulas to produce runtime errors. + +### Scenario 3: User provides a file with suspected formula errors + +When a user submits a file and reports wrong values or visible errors: + +```bash +# Step 1: Static scan — find all error cells +python3 SKILL_DIR/scripts/formula_check.py /path/to/user_file.xlsx --json > /tmp/validation_results.json + +# Step 2: Unpack for manual inspection +python3 SKILL_DIR/scripts/xlsx_unpack.py /path/to/user_file.xlsx /tmp/xlsx_inspect/ + +# Step 3: Dynamic recalculation +python3 SKILL_DIR/scripts/libreoffice_recalc.py /path/to/user_file.xlsx /tmp/user_file_recalc.xlsx + +# Step 4: Re-validate recalculated file +python3 SKILL_DIR/scripts/formula_check.py /tmp/user_file_recalc.xlsx --json > /tmp/validation_after_recalc.json + +# Step 5: Compare before and after +python3 - <<'EOF' +import json +before = json.load(open("/tmp/validation_results.json")) +after = json.load(open("/tmp/validation_after_recalc.json")) +print(f"Before recalc: {before['error_count']} errors") +print(f"After recalc: {after['error_count']} errors") +EOF +``` + +If errors appear only after recalculation (not in the original static scan), the formulas were syntactically correct but produce wrong results at runtime. These are runtime errors that require formula-level fixes, not XML-structure fixes. + +If errors appear in both scans, they were already cached in `` before recalculation — the file was previously opened by Excel/LibreOffice and the errors persisted. + +--- + +## Critical Pitfalls + +**Pitfall 1: openpyxl `data_only=True` destroys formulas.** +Opening a workbook with `data_only=True` reads cached values instead of formulas. If you then save the workbook, all `` elements are permanently removed and replaced with their last-cached values. Never use this mode for validation workflows. + +**Pitfall 2: Empty `` is not the same as a passing formula.** +A freshly generated file has empty `` elements for all formula cells. formula_check.py will not report these as errors — they are not yet errors. They become errors only after recalculation if the calculated value is an error type. This is why Tier 2 is mandatory. + +**Pitfall 3: Shared formula errors affect the entire range.** +If a shared formula's primary cell has a broken reference, every cell in the shared range (`ref="D2:D100"`) inherits that broken reference. The count of logical errors can be much larger than the count of distinct error entries in formula_check.py output. When fixing a broken shared formula, fix the primary cell's `` element; the consumers (``) automatically inherit the corrected formula. + +**Pitfall 4: Sheet names are case-sensitive.** +`=q1!B5` and `=Q1!B5` are different references. Excel internally treats them the same, but formula_check.py's string comparison is case-sensitive. If a formula uses a lowercase sheet name that matches an uppercase sheet in the workbook, it will be flagged as a broken reference. The fix is to match the exact case in `workbook.xml`. + +**Pitfall 5: `--convert-to xlsx` does not guarantee formula preservation.** +LibreOffice's conversion can occasionally alter certain formula types (array formulas, dynamic array functions like `SORT`, `UNIQUE`). After Tier 2, if the recalculated file shows formula changes unrelated to error fixing, do not deliver the recalculated file directly — use the original file with targeted XML fixes instead. diff --git a/backend/app/skills_builtin/minimax-xlsx/scripts/formula_check.py b/backend/app/skills_builtin/minimax-xlsx/scripts/formula_check.py new file mode 100644 index 0000000..ee3ce15 --- /dev/null +++ b/backend/app/skills_builtin/minimax-xlsx/scripts/formula_check.py @@ -0,0 +1,422 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: MIT +""" +formula_check.py — Static formula validator for xlsx files. + +Usage: + python3 formula_check.py + python3 formula_check.py --json # machine-readable output + python3 formula_check.py --report # standardized validation report (JSON) + python3 formula_check.py --report -o out # report to file + python3 formula_check.py --sheet Sales # limit to one sheet + python3 formula_check.py --summary # error counts only, no details + +What it checks: +1. Error-value cells: #REF! — all 7 Excel error types +2. Broken cross-sheet references: formula references a sheet not in workbook.xml +3. Broken named-range references: formula references a name not in workbook.xml +4. Shared formula integrity: shared formula primary cell exists and has formula text +5. Missing on t="e" cells (malformed XML) + +Checks NOT performed (require dynamic recalculation): +- Runtime errors that only appear after formulas execute (#DIV/0! on empty denominator, etc.) + -> Use libreoffice_recalc.py + re-run formula_check.py for dynamic validation + +Exit code: + 0 — no errors found + 1 — errors detected (or file cannot be opened) +""" + +import sys +import zipfile +import xml.etree.ElementTree as ET +import re +import json + +# OOXML SpreadsheetML namespace +NS = "http://schemas.openxmlformats.org/spreadsheetml/2006/main" +NSP = f"{{{NS}}}" + +# All 7 standard Excel formula error types +EXCEL_ERRORS = {"#REF!", "#DIV/0!", "#VALUE!", "#NAME?", "#NULL!", "#NUM!", "#N/A"} + +# Excel built-in function names (subset of common ones) — used for #NAME? heuristic +# Full list: https://support.microsoft.com/en-us/office/excel-functions-alphabetical +_BUILTIN_FUNCTIONS = { + "ABS", "AND", "AVERAGE", "AVERAGEIF", "AVERAGEIFS", "CEILING", "CHOOSE", + "COUNTA", "COUNTIF", "COUNTIFS", "COUNT", "DATE", "EDATE", "EOMONTH", + "FALSE", "FILTER", "FIND", "FLOOR", "IF", "IFERROR", "IFNA", "IFS", + "INDEX", "INDIRECT", "INT", "IRR", "ISBLANK", "ISERROR", "ISNA", "ISNUMBER", + "LARGE", "LEFT", "LEN", "LOOKUP", "LOWER", "MATCH", "MAX", "MID", "MIN", + "MOD", "MONTH", "NETWORKDAYS", "NOT", "NOW", "NPV", "OFFSET", "OR", + "PMT", "PV", "RAND", "RANK", "RIGHT", "ROUND", "ROUNDDOWN", "ROUNDUP", + "ROW", "ROWS", "SEARCH", "SMALL", "SORT", "SQRT", "SUBSTITUTE", "SUM", + "SUMIF", "SUMIFS", "SUMPRODUCT", "TEXT", "TODAY", "TRANSPOSE", "TRIM", + "TRUE", "UNIQUE", "UPPER", "VALUE", "VLOOKUP", "HLOOKUP", "XLOOKUP", + "XMATCH", "XNPV", "XIRR", "YEAR", "YEARFRAC", +} + + +def get_sheet_names(z: zipfile.ZipFile) -> dict[str, str]: + """Return dict of {r:id -> sheet_name} from workbook.xml.""" + wb_xml = z.read("xl/workbook.xml") + wb = ET.fromstring(wb_xml) + rel_ns = "http://schemas.openxmlformats.org/officeDocument/2006/relationships" + sheets = {} + for sheet in wb.findall(f".//{NSP}sheet"): + name = sheet.get("name", "") + rid = sheet.get(f"{{{rel_ns}}}id", "") + sheets[rid] = name + return sheets + + +def get_defined_names(z: zipfile.ZipFile) -> set[str]: + """Return set of named ranges defined in workbook.xml .""" + wb_xml = z.read("xl/workbook.xml") + wb = ET.fromstring(wb_xml) + names = set() + for dn in wb.findall(f".//{NSP}definedName"): + n = dn.get("name", "") + if n: + names.add(n) + return names + + +def get_sheet_files(z: zipfile.ZipFile) -> dict[str, str]: + """Return dict of {r:id -> xl/worksheets/sheetN.xml} from workbook.xml.rels.""" + rels_xml = z.read("xl/_rels/workbook.xml.rels") + rels = ET.fromstring(rels_xml) + mapping = {} + for rel in rels: + rid = rel.get("Id", "") + target = rel.get("Target", "") + if "worksheets" in target: + # Target may be relative: "worksheets/sheet1.xml" -> "xl/worksheets/sheet1.xml" + if not target.startswith("xl/"): + target = "xl/" + target + mapping[rid] = target + return mapping + + +def extract_sheet_refs(formula: str) -> list[str]: + """ + Extract all sheet names referenced in a formula string. + + Handles: + - 'Sheet Name'!A1 (quoted, may contain spaces) + - SheetName!A1 (unquoted, no spaces) + + Returns a list of sheet name strings (may contain duplicates if the same + sheet is referenced multiple times in one formula). + """ + refs = [] + # Quoted sheet names: 'Sheet Name'! + for m in re.finditer(r"'([^']+)'!", formula): + refs.append(m.group(1)) + # Unquoted sheet names: SheetName! (not preceded by a single quote) + for m in re.finditer(r"(? list[str]: + """ + Extract identifiers in a formula that could be named range references. + + Heuristic: identifiers that: + - Are not preceded by a sheet reference (no "!" before them) + - Are not followed by "(" (which would make them function calls) + - Match the pattern of a name (letters/underscore start, alphanumeric/underscore body) + - Are not single-letter column references or row references + + This is approximate. False positives are possible; false negatives are rare. + """ + names = [] + # Remove quoted sheet references first to avoid false matches + formula_clean = re.sub(r"'[^']*'![A-Z$0-9:]+", "", formula) + formula_clean = re.sub(r"[A-Za-z_][A-Za-z0-9_.]*![A-Z$0-9:]+", "", formula_clean) + # Find identifiers not followed by "(" (not function calls) + for m in re.finditer(r"\b([A-Za-z_][A-Za-z0-9_]{2,})\b(?!\s*\()", formula_clean): + candidate = m.group(1) + # Exclude Excel cell references like A1, B10, AA100 + if re.fullmatch(r"[A-Z]{1,3}[0-9]+", candidate): + continue + # Exclude built-in function names (they appear without parens sometimes in array formulas) + if candidate.upper() in _BUILTIN_FUNCTIONS: + continue + names.append(candidate) + return names + + +def check(xlsx_path: str, sheet_filter: str | None = None) -> dict: + """ + Run all static checks on the given xlsx file. + + Args: + xlsx_path: path to the .xlsx file + sheet_filter: if provided, only check the sheet with this name + + Returns: + A dict with keys: + file, sheets_checked, formula_count, shared_formula_ranges, + error_count, errors + """ + results = { + "file": xlsx_path, + "sheets_checked": [], + "formula_count": 0, + "shared_formula_ranges": 0, # number of shared formula definitions + "error_count": 0, + "errors": [], + } + + try: + z = zipfile.ZipFile(xlsx_path, "r") + except (zipfile.BadZipFile, FileNotFoundError) as e: + results["errors"].append({"type": "file_error", "message": str(e)}) + results["error_count"] = 1 + return results + + with z: + sheet_names = get_sheet_names(z) + sheet_files = get_sheet_files(z) + valid_sheet_names = set(sheet_names.values()) + defined_names = get_defined_names(z) + + for rid, sheet_name in sheet_names.items(): + # Apply sheet filter if requested + if sheet_filter and sheet_name != sheet_filter: + continue + + ws_file = sheet_files.get(rid) + if not ws_file or ws_file not in z.namelist(): + continue + + results["sheets_checked"].append(sheet_name) + ws_xml = z.read(ws_file) + ws = ET.fromstring(ws_xml) + + # Track shared formula IDs seen on this sheet (si -> primary cell ref) + shared_primary: dict[str, str] = {} + + for cell in ws.findall(f".//{NSP}c"): + cell_ref = cell.get("r", "?") + cell_type = cell.get("t", "n") + + # ── Check 1: error-value cell ────────────────────────────── + if cell_type == "e": + v_elem = cell.find(f"{NSP}v") + if v_elem is None: + # Malformed: t="e" but no — record as structural issue + results["errors"].append( + { + "type": "malformed_error_cell", + "sheet": sheet_name, + "cell": cell_ref, + "detail": "Cell has t='e' but no child element", + } + ) + results["error_count"] += 1 + else: + error_val = v_elem.text or "#UNKNOWN" + f_elem = cell.find(f"{NSP}f") + results["errors"].append( + { + "type": "error_value", + "error": error_val, + "sheet": sheet_name, + "cell": cell_ref, + # Include formula text if present + "formula": f_elem.text if (f_elem is not None and f_elem.text) else None, + } + ) + results["error_count"] += 1 + + # ── Check 2 & 3: formulas ────────────────────────────────── + f_elem = cell.find(f"{NSP}f") + if f_elem is None: + continue + + f_type = f_elem.get("t", "") # "shared", "array", or "" for normal + f_si = f_elem.get("si") # shared formula group ID + + # Count formulas: + # - Normal formulas: always count + # - Shared formula PRIMARY (has text + ref attribute): count once + # - Shared formula CONSUMER (si only, no text): do NOT count separately + # (they are covered by the primary's ref range) + if f_type == "shared" and f_elem.text is None: + # Consumer cell: skip formula counting and cross-ref checks + # (the primary cell already covers this formula) + continue + + formula = f_elem.text or "" + + if f_type == "shared" and f_elem.get("ref"): + results["shared_formula_ranges"] += 1 + if f_si is not None: + shared_primary[f_si] = cell_ref + + if formula: + results["formula_count"] += 1 + + # Check 2: cross-sheet references + for ref_sheet in extract_sheet_refs(formula): + if ref_sheet not in valid_sheet_names: + results["errors"].append( + { + "type": "broken_sheet_ref", + "sheet": sheet_name, + "cell": cell_ref, + "formula": formula, + "missing_sheet": ref_sheet, + "valid_sheets": sorted(valid_sheet_names), + } + ) + results["error_count"] += 1 + + # Check 3: named range references + # Only flag if the name is not a built-in and not a sheet-prefixed ref + for name_ref in extract_name_refs(formula): + if name_ref not in defined_names: + results["errors"].append( + { + "type": "unknown_name_ref", + "sheet": sheet_name, + "cell": cell_ref, + "formula": formula, + "unknown_name": name_ref, + "defined_names": sorted(defined_names), + "note": "Heuristic check — verify manually if this is a false positive", + } + ) + results["error_count"] += 1 + + return results + + +def build_report(results: dict) -> dict: + """ + Transform raw check() output into a standardized validation report. + + Usage: + python3 formula_check.py --report # JSON report to stdout + python3 formula_check.py --report -o out # JSON report to file + """ + from collections import Counter + + errors = results.get("errors", []) + error_types = [e.get("error", e.get("type", "unknown")) for e in errors] + + return { + "status": "success" if results["error_count"] == 0 else "errors_found", + "file": results["file"], + "sheets_checked": results["sheets_checked"], + "total_formulas": results["formula_count"], + "total_errors": results["error_count"], + "shared_formula_ranges": results.get("shared_formula_ranges", 0), + "errors_by_type": dict(Counter(error_types)) if errors else {}, + "errors": errors, + } + + +def main() -> None: + use_json = "--json" in sys.argv + use_report = "--report" in sys.argv + summary_only = "--summary" in sys.argv + output_file = None + sheet_filter = None + args_clean = [] + + i = 1 + while i < len(sys.argv): + arg = sys.argv[i] + if arg == "--sheet" and i + 1 < len(sys.argv): + sheet_filter = sys.argv[i + 1] + i += 2 + elif arg == "-o" and i + 1 < len(sys.argv): + output_file = sys.argv[i + 1] + i += 2 + elif arg.startswith("--"): + i += 1 # skip flags already handled + else: + args_clean.append(arg) + i += 1 + + if not args_clean: + print("Usage: formula_check.py [--json] [--report [-o FILE]] [--sheet NAME] [--summary]") + sys.exit(1) + + results = check(args_clean[0], sheet_filter=sheet_filter) + + if use_report: + report = build_report(results) + output = json.dumps(report, indent=2, ensure_ascii=False) + if output_file: + with open(output_file, "w", encoding="utf-8") as f: + f.write(output + "\n") + else: + print(output) + sys.exit(1 if results["error_count"] > 0 else 0) + + if use_json: + print(json.dumps(results, indent=2, ensure_ascii=False)) + sys.exit(1 if results["error_count"] > 0 else 0) + + # Human-readable output + sheets = ", ".join(results["sheets_checked"]) or "(none)" + if sheet_filter: + sheets = f"{sheet_filter} (filtered)" + + print(f"File : {results['file']}") + print(f"Sheets : {sheets}") + print(f"Formulas checked : {results['formula_count']} distinct formula cells") + print(f"Shared formula ranges : {results['shared_formula_ranges']} ranges") + print(f"Errors found : {results['error_count']}") + + if not summary_only and results["errors"]: + print("\n── Error Details ──") + for e in results["errors"]: + if e["type"] == "error_value": + formula_hint = f" (formula: {e['formula']})" if e.get("formula") else "" + print(f" [FAIL] [{e['sheet']}!{e['cell']}] contains {e['error']}{formula_hint}") + elif e["type"] == "broken_sheet_ref": + print( + f" [FAIL] [{e['sheet']}!{e['cell']}] references missing sheet " + f"'{e['missing_sheet']}'" + ) + print(f" Formula: {e['formula']}") + print(f" Valid sheets: {e.get('valid_sheets', [])}") + elif e["type"] == "unknown_name_ref": + print( + f" [WARN] [{e['sheet']}!{e['cell']}] uses unknown name " + f"'{e['unknown_name']}' (heuristic — verify manually)" + ) + print(f" Formula: {e['formula']}") + print(f" Defined names: {e.get('defined_names', [])}") + elif e["type"] == "malformed_error_cell": + print(f" [FAIL] [{e['sheet']}!{e['cell']}] malformed error cell: {e['detail']}") + elif e["type"] == "file_error": + print(f" [FAIL] File error: {e['message']}") + print() + + if results["error_count"] == 0: + print("PASS — No formula errors detected") + else: + # Separate definitive failures from heuristic warnings + hard_errors = [e for e in results["errors"] if e["type"] != "unknown_name_ref"] + warnings = [e for e in results["errors"] if e["type"] == "unknown_name_ref"] + if hard_errors: + print(f"FAIL — {len(hard_errors)} error(s) must be fixed before delivery") + if warnings: + print(f"WARN — {len(warnings)} heuristic warning(s) require manual review") + sys.exit(1) + else: + # Only heuristic warnings — do not block delivery but alert + print(f"PASS with WARN — {len(warnings)} heuristic warning(s) require manual review") + # Exit 0: heuristic warnings alone do not block delivery + sys.exit(0) + + +if __name__ == "__main__": + main() diff --git a/backend/app/skills_builtin/minimax-xlsx/scripts/libreoffice_recalc.py b/backend/app/skills_builtin/minimax-xlsx/scripts/libreoffice_recalc.py new file mode 100644 index 0000000..5699e89 --- /dev/null +++ b/backend/app/skills_builtin/minimax-xlsx/scripts/libreoffice_recalc.py @@ -0,0 +1,248 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: MIT +""" +libreoffice_recalc.py — Tier 2 dynamic formula recalculation via LibreOffice headless. + +Opens the xlsx file with the LibreOffice Calc engine, executes all formulas, writes +the computed values into the cache elements, and saves the result. This is the +closest server-side equivalent of "open in Excel and save." + +After recalculation, run formula_check.py on the output file to detect runtime errors +(#DIV/0!, #N/A, etc.) that only surface after actual computation. + +Usage: + python3 libreoffice_recalc.py input.xlsx output.xlsx + python3 libreoffice_recalc.py input.xlsx output.xlsx --timeout 90 + python3 libreoffice_recalc.py --check # check LibreOffice availability only + +Exit codes: + 0 — recalculation succeeded, output file written + 2 — LibreOffice not found (Tier 2 unavailable — not a hard failure, note in report) + 1 — LibreOffice found but recalculation failed (timeout, crash, bad file) +""" + +import subprocess +import sys +import shutil +import os +import tempfile +import argparse + + +# ── LibreOffice discovery ─────────────────────────────────────────────────── + +def find_soffice() -> str | None: + """ + Locate the soffice (LibreOffice) binary. + + Search order: + 1. macOS application bundle (default install location) + 2. PATH lookup for 'soffice' + 3. PATH lookup for 'libreoffice' (common on Linux) + """ + candidates = [ + "/Applications/LibreOffice.app/Contents/MacOS/soffice", # macOS + "soffice", # Linux / macOS if on PATH + "libreoffice", # alternative Linux name + ] + for c in candidates: + # shutil.which handles PATH lookup; also check absolute paths directly + found = shutil.which(c) + if found: + return found + if os.path.isfile(c) and os.access(c, os.X_OK): + return c + return None + + +def get_libreoffice_version(soffice: str) -> str: + """Return LibreOffice version string, or 'unknown' on failure.""" + try: + result = subprocess.run( + [soffice, "--version"], + capture_output=True, + timeout=10, + ) + return result.stdout.decode(errors="replace").strip() + except Exception: + return "unknown" + + +# ── Recalculation ─────────────────────────────────────────────────────────── + +def recalculate( + input_path: str, + output_path: str, + timeout: int = 60, +) -> tuple[bool, str]: + """ + Run LibreOffice headless recalculation on input_path, write result to output_path. + + Returns: + (success: bool, message: str) + + The message explains what happened (success or failure reason). + """ + soffice = find_soffice() + if not soffice: + return False, ( + "LibreOffice not found. Tier 2 validation is unavailable in this environment. " + "Install LibreOffice to enable dynamic formula recalculation.\n" + " macOS: brew install --cask libreoffice\n" + " Linux: sudo apt-get install -y libreoffice" + ) + + version = get_libreoffice_version(soffice) + + # Work on a copy in a temp directory to avoid side effects on the source file. + # LibreOffice writes the output using the same filename stem in --outdir. + with tempfile.TemporaryDirectory(prefix="xlsx_recalc_") as tmpdir: + tmp_input = os.path.join(tmpdir, os.path.basename(input_path)) + shutil.copy(input_path, tmp_input) + + cmd = [ + soffice, + "--headless", + "--norestore", # do not attempt to restore crashed sessions + "--infilter=Calc MS Excel 2007 XML", + "--convert-to", "xlsx", + "--outdir", tmpdir, + tmp_input, + ] + + try: + result = subprocess.run( + cmd, + capture_output=True, + timeout=timeout, + ) + except subprocess.TimeoutExpired: + return False, ( + f"LibreOffice timed out after {timeout}s. " + "The file may be too large or contain constructs that cause LibreOffice to hang. " + "Try increasing --timeout or simplify the file." + ) + except FileNotFoundError: + return False, f"LibreOffice binary not executable: {soffice}" + + if result.returncode != 0: + stderr = result.stderr.decode(errors="replace").strip() + stdout = result.stdout.decode(errors="replace").strip() + return False, ( + f"LibreOffice exited with code {result.returncode}.\n" + f"stderr: {stderr}\n" + f"stdout: {stdout}" + ) + + # LibreOffice writes: /.xlsx + stem = os.path.splitext(os.path.basename(tmp_input))[0] + tmp_output = os.path.join(tmpdir, stem + ".xlsx") + + if not os.path.isfile(tmp_output): + # Try to find any .xlsx file in tmpdir (LibreOffice may behave differently) + xlsx_files = [f for f in os.listdir(tmpdir) if f.endswith(".xlsx") and f != os.path.basename(tmp_input)] + if xlsx_files: + tmp_output = os.path.join(tmpdir, xlsx_files[0]) + else: + stdout = result.stdout.decode(errors="replace").strip() + return False, ( + f"LibreOffice succeeded (exit 0) but output file not found in {tmpdir}.\n" + f"stdout: {stdout}\n" + f"Files in tmpdir: {os.listdir(tmpdir)}" + ) + + # Copy recalculated file to final destination + os.makedirs(os.path.dirname(os.path.abspath(output_path)), exist_ok=True) + shutil.copy(tmp_output, output_path) + + return True, f"Recalculation complete. LibreOffice {version}. Output: {output_path}" + + +# ── CLI ───────────────────────────────────────────────────────────────────── + +def main() -> None: + parser = argparse.ArgumentParser( + description="LibreOffice headless formula recalculation for xlsx files.", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Examples: + # Basic recalculation + python3 libreoffice_recalc.py report.xlsx report_recalc.xlsx + + # With extended timeout for large files + python3 libreoffice_recalc.py big_model.xlsx big_model_recalc.xlsx --timeout 120 + + # Check if LibreOffice is available (useful in CI) + python3 libreoffice_recalc.py --check + + # Full validation pipeline + python3 libreoffice_recalc.py input.xlsx /tmp/recalc.xlsx && \\ + python3 formula_check.py /tmp/recalc.xlsx +""", + ) + parser.add_argument("input", nargs="?", help="Input xlsx file path") + parser.add_argument("output", nargs="?", help="Output xlsx file path (recalculated)") + parser.add_argument( + "--timeout", + type=int, + default=60, + metavar="SECONDS", + help="Maximum time to wait for LibreOffice (default: 60)", + ) + parser.add_argument( + "--check", + action="store_true", + help="Only check if LibreOffice is available, then exit", + ) + + args = parser.parse_args() + + # ── --check mode ───────────────────────────────────────────────────────── + if args.check: + soffice = find_soffice() + if soffice: + version = get_libreoffice_version(soffice) + print(f"LibreOffice available: {soffice}") + print(f"Version: {version}") + sys.exit(0) + else: + print("LibreOffice NOT available.") + print("Tier 2 dynamic validation requires LibreOffice.") + print(" macOS: brew install --cask libreoffice") + print(" Linux: sudo apt-get install -y libreoffice") + sys.exit(2) + + # ── Recalculation mode ──────────────────────────────────────────────────── + if not args.input or not args.output: + parser.print_help() + sys.exit(1) + + if not os.path.isfile(args.input): + print(f"ERROR: Input file not found: {args.input}") + sys.exit(1) + + print(f"Input : {args.input}") + print(f"Output : {args.output}") + print(f"Timeout: {args.timeout}s") + print() + + success, message = recalculate(args.input, args.output, timeout=args.timeout) + + if success: + print(f"OK: {message}") + print() + print("Next step: run formula_check.py on the recalculated file to detect runtime errors:") + print(f" python3 formula_check.py {args.output}") + sys.exit(0) + else: + # Distinguish "not installed" (exit 2) from "failed" (exit 1) + if "not found" in message.lower() or "not available" in message.lower(): + print(f"SKIP (Tier 2 unavailable): {message}") + sys.exit(2) + else: + print(f"ERROR: {message}") + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/backend/app/skills_builtin/minimax-xlsx/scripts/shared_strings_builder.py b/backend/app/skills_builtin/minimax-xlsx/scripts/shared_strings_builder.py new file mode 100644 index 0000000..9ef3599 --- /dev/null +++ b/backend/app/skills_builtin/minimax-xlsx/scripts/shared_strings_builder.py @@ -0,0 +1,163 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: MIT +""" +shared_strings_builder.py — Generate a valid sharedStrings.xml from a list of strings. + +Usage (strings as command-line arguments): + python3 shared_strings_builder.py "Revenue" "Cost" "Gross Profit" > sharedStrings.xml + +Usage (strings from a file, one per line): + python3 shared_strings_builder.py --file strings.txt > sharedStrings.xml + +Usage (print index table instead of XML, for reference): + python3 shared_strings_builder.py --index "Revenue" "Cost" "Gross Profit" + python3 shared_strings_builder.py --index --file strings.txt + +Output format: + Valid xl/sharedStrings.xml written to stdout. + Redirect to the correct path: + python3 shared_strings_builder.py "A" "B" > /tmp/xlsx_work/xl/sharedStrings.xml + +Notes: + - Strings are de-duplicated: identical strings appear only once in the table. + - The 'count' attribute equals the number of unique strings (appropriate for new files + where each string is used in exactly one cell). If a string appears in multiple cells, + manually increment 'count' by the number of extra references. + - Special characters (&, <, >) are automatically XML-escaped. + - Leading/trailing spaces are preserved with xml:space="preserve". +""" + +import sys +import html +import argparse + + +HEADER = '' +SST_NS = "http://schemas.openxmlformats.org/spreadsheetml/2006/main" + + +def escape_text(s: str) -> tuple[str, bool]: + """ + Return (escaped_text, needs_preserve). + needs_preserve is True if the string has leading or trailing whitespace. + """ + escaped = html.escape(s, quote=False) + needs_preserve = s != s.strip() + return escaped, needs_preserve + + +def build_xml(strings: list[str]) -> str: + """Build sharedStrings.xml content from a list of unique strings.""" + n = len(strings) + lines = [ + HEADER, + f'', + ] + for i, s in enumerate(strings): + escaped, preserve = escape_text(s) + if preserve: + lines.append(f' {escaped}' + f' ') + else: + lines.append(f' {escaped} ') + lines.append("") + return "\n".join(lines) + "\n" + + +def build_index_table(strings: list[str]) -> str: + """Return a human-readable index table (for agent reference, not written to file).""" + lines = [ + f"{'Index':<6} String", + "-" * 50, + ] + for i, s in enumerate(strings): + lines.append(f"{i:<6} {s!r}") + lines.append("") + lines.append( + f"Total: {len(strings)} unique strings. " + "Use these indices in N cells." + ) + return "\n".join(lines) + "\n" + + +def deduplicate(strings: list[str]) -> list[str]: + """Remove duplicates while preserving first-occurrence order.""" + seen: set[str] = set() + result: list[str] = [] + for s in strings: + if s not in seen: + seen.add(s) + result.append(s) + return result + + +def load_from_file(path: str) -> list[str]: + """Read one string per non-empty line from a file.""" + with open(path, encoding="utf-8") as f: + return [line.rstrip("\n") for line in f if line.strip()] + + +def main() -> None: + parser = argparse.ArgumentParser( + description="Generate xl/sharedStrings.xml from a list of strings.", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=__doc__, + ) + parser.add_argument( + "strings", + nargs="*", + metavar="STRING", + help="String values to include in the shared string table.", + ) + parser.add_argument( + "--file", + "-f", + metavar="PATH", + help="Read strings from a file (one string per line) instead of arguments.", + ) + parser.add_argument( + "--index", + action="store_true", + help="Print a human-readable index table instead of XML output.", + ) + args = parser.parse_args() + + if args.file: + try: + raw = load_from_file(args.file) + except FileNotFoundError: + print(f"ERROR: File not found: {args.file}", file=sys.stderr) + sys.exit(1) + except OSError as e: + print(f"ERROR: Cannot read file: {e}", file=sys.stderr) + sys.exit(1) + else: + raw = list(args.strings) + + if not raw: + print( + "ERROR: No strings provided.\n" + "Usage: shared_strings_builder.py \"String1\" \"String2\" ...\n" + " or: shared_strings_builder.py --file strings.txt", + file=sys.stderr, + ) + sys.exit(1) + + strings = deduplicate(raw) + + if len(strings) < len(raw): + removed = len(raw) - len(strings) + print( + f"Note: {removed} duplicate(s) removed. " + f"{len(strings)} unique strings in table.", + file=sys.stderr, + ) + + if args.index: + print(build_index_table(strings)) + else: + print(build_xml(strings), end="") + + +if __name__ == "__main__": + main() diff --git a/backend/app/skills_builtin/minimax-xlsx/scripts/style_audit.py b/backend/app/skills_builtin/minimax-xlsx/scripts/style_audit.py new file mode 100644 index 0000000..96205f8 --- /dev/null +++ b/backend/app/skills_builtin/minimax-xlsx/scripts/style_audit.py @@ -0,0 +1,575 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: MIT +""" +style_audit.py — Financial formatting compliance checker for xlsx files. + +Audits an xlsx file (or an unpacked xlsx directory) and reports: +1. Style system integrity: count attributes match actual element counts +2. Color-role violations: formula cells with blue font, input cells with black font +3. Year-format violations: cells containing 4-digit years using comma-format +4. Percentage value violations: percentage-formatted cells with values > 1 (likely meant 0.08 not 8) +5. Style index out-of-range: s attribute exceeds cellXfs count +6. fills[0]/fills[1] presence check (OOXML spec requirement) + +Usage: + python3 style_audit.py input.xlsx # audit a packed xlsx + python3 style_audit.py /tmp/xlsx_work/ # audit an unpacked directory + python3 style_audit.py input.xlsx --json # machine-readable output + python3 style_audit.py input.xlsx --summary # counts only, no detail + +Exit code: + 0 — no violations found + 1 — violations detected (or file cannot be opened) +""" + +import sys +import os +import zipfile +import xml.etree.ElementTree as ET +import json +import re +import tempfile +import shutil + +NS = "http://schemas.openxmlformats.org/spreadsheetml/2006/main" +NSP = f"{{{NS}}}" + +# Predefined style index semantics from minimal_xlsx template. +# Maps cellXfs index -> (role, font_color_expectation, numFmt_type) +# role: "input" = blue expected, "formula" = black/green expected, "header" = any, "any" = skip +TEMPLATE_SLOT_ROLES = { + 0: ("any", None, None), + 1: ("input", "blue", "general"), + 2: ("formula", "black", "general"), + 3: ("formula", "green", "general"), + 4: ("any", None, "general"), # header + 5: ("input", "blue", "currency"), + 6: ("formula", "black", "currency"), + 7: ("input", "blue", "percent"), + 8: ("formula", "black", "percent"), + 9: ("input", "blue", "integer"), + 10: ("formula", "black", "integer"), + 11: ("input", "blue", "year"), + 12: ("input", "blue", "general"), # highlight +} + +# AARRGGBB values for each role color +BLUE_RGB = "000000ff" +BLACK_RGB = "00000000" +GREEN_RGB = "00008000" +RED_RGB = "00ff0000" + +# numFmtIds that represent percentage formats (built-in + common custom) +PERCENT_FMT_IDS = {9, 10, 165, 170} + +# numFmtIds that use comma separator (would corrupt year display) +COMMA_FMT_IDS = {3, 4, 167, 168} # #,##0 style — 4-digit years would show as 2,024 + + +def _parse_styles(styles_xml: bytes) -> dict: + """Parse styles.xml and return structured data.""" + root = ET.fromstring(styles_xml) + + def find(tag): + return root.find(f"{NSP}{tag}") + + # numFmts + num_fmts = {} # id -> formatCode + nf_elem = find("numFmts") + if nf_elem is not None: + declared_count = int(nf_elem.get("count", "0")) + actual_count = len(nf_elem) + for nf in nf_elem: + fid = int(nf.get("numFmtId", "0")) + num_fmts[fid] = nf.get("formatCode", "") + else: + declared_count = 0 + actual_count = 0 + + # fonts — extract color and bold flag + fonts = [] + fonts_elem = find("fonts") + fonts_declared = 0 + if fonts_elem is not None: + fonts_declared = int(fonts_elem.get("count", "0")) + for font in fonts_elem: + color_elem = font.find(f"{NSP}color") + bold_elem = font.find(f"{NSP}b") + if color_elem is not None: + rgb = color_elem.get("rgb", "").lower() + theme = color_elem.get("theme") + else: + rgb = "" + theme = None + fonts.append({ + "rgb": rgb, + "theme": theme, + "bold": bold_elem is not None, + }) + + # fills + fills = [] + fills_elem = find("fills") + fills_declared = 0 + if fills_elem is not None: + fills_declared = int(fills_elem.get("count", "0")) + for fill in fills_elem: + pf = fill.find(f"{NSP}patternFill") + pattern_type = pf.get("patternType", "") if pf is not None else "" + fills.append({"patternType": pattern_type}) + + # cellXfs + xfs = [] + xfs_elem = find("cellXfs") + xfs_declared = 0 + if xfs_elem is not None: + xfs_declared = int(xfs_elem.get("count", "0")) + for xf in xfs_elem: + xfs.append({ + "numFmtId": int(xf.get("numFmtId", "0")), + "fontId": int(xf.get("fontId", "0")), + "fillId": int(xf.get("fillId", "0")), + "borderId": int(xf.get("borderId", "0")), + }) + + return { + "num_fmts": num_fmts, + "num_fmts_declared": declared_count, + "num_fmts_actual": actual_count, + "fonts": fonts, + "fonts_declared": fonts_declared, + "fonts_actual": len(fonts), + "fills": fills, + "fills_declared": fills_declared, + "fills_actual": len(fills), + "xfs": xfs, + "xfs_declared": xfs_declared, + "xfs_actual": len(xfs), + } + + +def _is_blue_font(font: dict) -> bool: + return font["rgb"] == BLUE_RGB + + +def _is_black_font(font: dict) -> bool: + return font["rgb"] == BLACK_RGB or (font["rgb"] == "" and font["theme"] is not None) + + +def _is_green_font(font: dict) -> bool: + return font["rgb"] == GREEN_RGB + + +def _fmt_is_percent(num_fmt_id: int, num_fmts: dict) -> bool: + if num_fmt_id in PERCENT_FMT_IDS: + return True + fmt_code = num_fmts.get(num_fmt_id, "") + return "%" in fmt_code + + +def _fmt_is_comma(num_fmt_id: int, num_fmts: dict) -> bool: + if num_fmt_id in COMMA_FMT_IDS: + return True + fmt_code = num_fmts.get(num_fmt_id, "") + # formatCode has comma separator if it contains #,##0 but not a trailing , (scale) + return "#,##" in fmt_code and not fmt_code.endswith(",") and not fmt_code.endswith(",\"M\"") and not fmt_code.endswith(",\"K\"") + + +def _looks_like_year(value_text: str) -> bool: + """True if value is a 4-digit year between 1900 and 2100.""" + try: + v = int(float(value_text)) + return 1900 <= v <= 2100 + except (ValueError, TypeError): + return False + + +def _audit(styles_xml: bytes, sheet_xmls: list[tuple[str, bytes]]) -> dict: + """ + Run all formatting compliance checks. + + Args: + styles_xml: content of xl/styles.xml + sheet_xmls: list of (sheet_name, xml_bytes) for each worksheet + + Returns: + dict with violations and summary + """ + results = { + "violations": [], + "warnings": [], + "summary": {}, + } + v = results["violations"] + w = results["warnings"] + + styles = _parse_styles(styles_xml) + fonts = styles["fonts"] + xfs = styles["xfs"] + num_fmts = styles["num_fmts"] + + # ── Check A: count attribute integrity ────────────────────────────────── + if styles["fonts_declared"] != styles["fonts_actual"]: + v.append({ + "type": "count_mismatch", + "element": "fonts", + "declared": styles["fonts_declared"], + "actual": styles["fonts_actual"], + "fix": f"Update ", + }) + if styles["fills_declared"] != styles["fills_actual"]: + v.append({ + "type": "count_mismatch", + "element": "fills", + "declared": styles["fills_declared"], + "actual": styles["fills_actual"], + "fix": f"Update ", + }) + if styles["xfs_declared"] != styles["xfs_actual"]: + v.append({ + "type": "count_mismatch", + "element": "cellXfs", + "declared": styles["xfs_declared"], + "actual": styles["xfs_actual"], + "fix": f"Update ", + }) + + # ── Check B: fills[0] and fills[1] presence ────────────────────────────── + fills = styles["fills"] + if len(fills) < 2: + v.append({ + "type": "missing_required_fills", + "detail": "fills[0] (none) and fills[1] (gray125) are required by OOXML spec", + "fix": "Prepend and ", + }) + else: + if fills[0].get("patternType") != "none": + v.append({ + "type": "fills_0_corrupted", + "detail": f"fills[0] patternType='{fills[0].get('patternType')}', must be 'none'", + "fix": "Set fills[0] patternFill patternType to 'none'", + }) + if fills[1].get("patternType") != "gray125": + v.append({ + "type": "fills_1_corrupted", + "detail": f"fills[1] patternType='{fills[1].get('patternType')}', must be 'gray125'", + "fix": "Set fills[1] patternFill patternType to 'gray125'", + }) + + # ── Check C: per-cell style violations ─────────────────────────────────── + total_cells = 0 + formula_cells = 0 + input_cells = 0 + + for sheet_name, sheet_xml in sheet_xmls: + ws = ET.fromstring(sheet_xml) + + for cell in ws.findall(f".//{NSP}c"): + cell_ref = cell.get("r", "?") + s_attr = cell.get("s") + has_formula = cell.find(f"{NSP}f") is not None + v_elem = cell.find(f"{NSP}v") + value_text = v_elem.text if v_elem is not None else None + total_cells += 1 + + # Skip cells with no style + if s_attr is None: + continue + + try: + s_idx = int(s_attr) + except ValueError: + continue + + # Check C1: s index out of range + if s_idx >= len(xfs): + v.append({ + "type": "style_index_out_of_range", + "sheet": sheet_name, + "cell": cell_ref, + "s": s_idx, + "cellXfs_count": len(xfs), + "fix": f"s={s_idx} exceeds cellXfs count={len(xfs)}; add missing entries or lower s value", + }) + continue + + xf = xfs[s_idx] + font_id = xf["fontId"] + num_fmt_id = xf["numFmtId"] + + if font_id >= len(fonts): + v.append({ + "type": "font_index_out_of_range", + "sheet": sheet_name, + "cell": cell_ref, + "fontId": font_id, + "fonts_count": len(fonts), + "fix": f"fontId={font_id} exceeds fonts count={len(fonts)}; add missing entries", + }) + continue + + font = fonts[font_id] + + # Check C2: color-role violation — formula cell with blue font + if has_formula and _is_blue_font(font): + formula_cells += 1 + f_elem = cell.find(f"{NSP}f") + formula_text = f_elem.text if f_elem is not None else "" + v.append({ + "type": "formula_cell_blue_font", + "sheet": sheet_name, + "cell": cell_ref, + "s": s_idx, + "formula": formula_text, + "fix": "Formula cells must use black font (formula) or green font (cross-sheet ref). " + "Use style index 2/6/8/10 (black) or 3/13 (green) instead.", + }) + + # Check C3: color-role violation — non-formula cell with explicit black + # (only flag if it looks like it should be an input — has a numeric value) + if (not has_formula and _is_black_font(font) + and value_text is not None + and not font.get("bold") + and num_fmt_id not in (0,) # skip general-format black (could be label) + ): + try: + float(value_text) + # It's a numeric value with black font — possible missing blue input marker + w.append({ + "type": "numeric_input_may_lack_blue", + "sheet": sheet_name, + "cell": cell_ref, + "s": s_idx, + "value": value_text, + "note": "Hardcoded numeric value has black font — if this is a user-editable " + "assumption, change to blue-font input style (e.g. s=1/5/7/9/11/12).", + }) + except (ValueError, TypeError): + pass + + # Check C4: year value with comma-formatted numFmt + if value_text and _looks_like_year(value_text) and _fmt_is_comma(num_fmt_id, num_fmts): + v.append({ + "type": "year_with_comma_format", + "sheet": sheet_name, + "cell": cell_ref, + "s": s_idx, + "value": value_text, + "numFmtId": num_fmt_id, + "fix": "Year values must use numFmtId=1 (format '0') to display as 2024 not 2,024. " + "Use style index 11 or a custom xf with numFmtId=1.", + }) + + # Check C5: percentage format with value > 1 (likely 8 instead of 0.08) + if value_text and _fmt_is_percent(num_fmt_id, num_fmts): + try: + pct_val = float(value_text) + if pct_val > 1.0: + w.append({ + "type": "percent_value_gt_1", + "sheet": sheet_name, + "cell": cell_ref, + "s": s_idx, + "value": value_text, + "displayed_as": f"{pct_val * 100:.0f}%", + "note": f"Value {value_text} with percentage format displays as {pct_val*100:.0f}%. " + "If intended rate is ~{:.0f}%, store as {:.4f} instead.".format( + pct_val, pct_val / 100 + ), + }) + except (ValueError, TypeError): + pass + + if has_formula: + formula_cells += 1 + elif value_text is not None: + input_cells += 1 + + results["summary"] = { + "total_cells_inspected": total_cells, + "formula_cells": formula_cells, + "input_cells": input_cells, + "violations": len(v), + "warnings": len(w), + } + + return results + + +def _load_from_xlsx(xlsx_path: str) -> tuple[bytes, list[tuple[str, bytes]]]: + """Load styles.xml and all sheet XMLs from a packed xlsx file.""" + with zipfile.ZipFile(xlsx_path, "r") as z: + styles_xml = z.read("xl/styles.xml") + + # Get sheet name mapping + wb_xml = z.read("xl/workbook.xml") + wb = ET.fromstring(wb_xml) + rel_ns = "http://schemas.openxmlformats.org/officeDocument/2006/relationships" + rels_xml = z.read("xl/_rels/workbook.xml.rels") + rels = ET.fromstring(rels_xml) + + rid_to_name = {} + for sheet in wb.findall(f".//{{{NS}}}sheet"): + rid = sheet.get(f"{{{rel_ns}}}id", "") + name = sheet.get("name", "") + rid_to_name[rid] = name + + rid_to_path = {} + for rel in rels: + rid = rel.get("Id", "") + target = rel.get("Target", "") + if "worksheets" in target: + if not target.startswith("xl/"): + target = "xl/" + target + rid_to_path[rid] = target + + sheet_xmls = [] + for rid, name in rid_to_name.items(): + path = rid_to_path.get(rid) + if path and path in z.namelist(): + sheet_xmls.append((name, z.read(path))) + + return styles_xml, sheet_xmls + + +def _load_from_dir(unpacked_dir: str) -> tuple[bytes, list[tuple[str, bytes]]]: + """Load styles.xml and all sheet XMLs from an unpacked directory.""" + styles_path = os.path.join(unpacked_dir, "xl", "styles.xml") + with open(styles_path, "rb") as f: + styles_xml = f.read() + + # Get sheet names from workbook.xml + wb_path = os.path.join(unpacked_dir, "xl", "workbook.xml") + wb = ET.fromstring(open(wb_path, "rb").read()) + rels_path = os.path.join(unpacked_dir, "xl", "_rels", "workbook.xml.rels") + rels = ET.fromstring(open(rels_path, "rb").read()) + + rel_ns = "http://schemas.openxmlformats.org/officeDocument/2006/relationships" + rid_to_name = {} + for sheet in wb.findall(f".//{{{NS}}}sheet"): + rid = sheet.get(f"{{{rel_ns}}}id", "") + name = sheet.get("name", "") + rid_to_name[rid] = name + + rid_to_path = {} + for rel in rels: + rid = rel.get("Id", "") + target = rel.get("Target", "") + if "worksheets" in target: + rid_to_path[rid] = target + + sheet_xmls = [] + ws_dir = os.path.join(unpacked_dir, "xl", "worksheets") + for rid, name in rid_to_name.items(): + rel_path = rid_to_path.get(rid, "") + # rel_path may be "worksheets/sheet1.xml" or absolute path + if rel_path.startswith("worksheets/"): + full = os.path.join(unpacked_dir, "xl", rel_path) + else: + full = os.path.join(unpacked_dir, "xl", "worksheets", os.path.basename(rel_path)) + if os.path.exists(full): + with open(full, "rb") as f: + sheet_xmls.append((name, f.read())) + + return styles_xml, sheet_xmls + + +def main() -> None: + use_json = "--json" in sys.argv + summary_only = "--summary" in sys.argv + + args_clean = [a for a in sys.argv[1:] if not a.startswith("--")] + if not args_clean: + print("Usage: style_audit.py [--json] [--summary]") + sys.exit(1) + + target = args_clean[0] + + try: + if os.path.isdir(target): + styles_xml, sheet_xmls = _load_from_dir(target) + elif target.endswith(".xlsx") or target.endswith(".xlsm"): + styles_xml, sheet_xmls = _load_from_xlsx(target) + else: + print(f"ERROR: unrecognized target '{target}' — must be .xlsx file or unpacked directory") + sys.exit(1) + except Exception as e: + print(f"ERROR loading file: {e}") + sys.exit(1) + + results = _audit(styles_xml, sheet_xmls) + + if use_json: + print(json.dumps(results, indent=2, ensure_ascii=False)) + sys.exit(1 if results["summary"]["violations"] > 0 else 0) + + # Human-readable output + s = results["summary"] + print(f"Target : {target}") + print(f"Cells : {s['total_cells_inspected']} inspected " + f"({s['formula_cells']} formula, {s['input_cells']} input)") + print(f"Violations : {s['violations']}") + print(f"Warnings : {s['warnings']}") + + if not summary_only: + if results["violations"]: + print("\n── Violations (must fix) ──") + for item in results["violations"]: + t = item["type"] + if t == "count_mismatch": + print(f" [FAIL] {item['element']} count mismatch: declared={item['declared']}, " + f"actual={item['actual']}") + print(f" Fix: {item['fix']}") + elif t == "missing_required_fills": + print(f" [FAIL] {item['detail']}") + print(f" Fix: {item['fix']}") + elif t in ("fills_0_corrupted", "fills_1_corrupted"): + print(f" [FAIL] {item['detail']}") + print(f" Fix: {item['fix']}") + elif t == "formula_cell_blue_font": + print(f" [FAIL] [{item['sheet']}!{item['cell']}] formula cell has blue font " + f"(role=input, but cell contains formula: {item.get('formula', '')})") + print(f" Fix: {item['fix']}") + elif t == "style_index_out_of_range": + print(f" [FAIL] [{item['sheet']}!{item['cell']}] s={item['s']} but " + f"cellXfs count={item['cellXfs_count']}") + print(f" Fix: {item['fix']}") + elif t == "font_index_out_of_range": + print(f" [FAIL] [{item['sheet']}!{item['cell']}] fontId={item['fontId']} but " + f"fonts count={item['fonts_count']}") + print(f" Fix: {item['fix']}") + elif t == "year_with_comma_format": + print(f" [FAIL] [{item['sheet']}!{item['cell']}] year value {item['value']} " + f"uses comma-format (numFmtId={item['numFmtId']}) — will display as " + f"{int(float(item['value'])):,}") + print(f" Fix: {item['fix']}") + else: + print(f" [FAIL] {item}") + + if results["warnings"] and not summary_only: + print("\n── Warnings (review recommended) ──") + for item in results["warnings"]: + t = item["type"] + if t == "numeric_input_may_lack_blue": + print(f" [WARN] [{item['sheet']}!{item['cell']}] numeric value={item['value']} " + f"has black font — if user-editable assumption, use blue-font input style") + elif t == "percent_value_gt_1": + print(f" [WARN] [{item['sheet']}!{item['cell']}] percent-format cell has " + f"value={item['value']} (displays as {item['displayed_as']}) — " + f"likely should be stored as decimal (e.g. 0.08 for 8%)") + else: + print(f" [WARN] {item}") + + print() + if s["violations"] == 0: + if s["warnings"] == 0: + print("PASS — Financial formatting is compliant") + else: + print(f"PASS with WARN — {s['warnings']} warning(s) need review") + else: + print(f"FAIL — {s['violations']} violation(s) must be fixed before delivery") + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/backend/app/skills_builtin/minimax-xlsx/scripts/xlsx_add_column.py b/backend/app/skills_builtin/minimax-xlsx/scripts/xlsx_add_column.py new file mode 100644 index 0000000..3374e3b --- /dev/null +++ b/backend/app/skills_builtin/minimax-xlsx/scripts/xlsx_add_column.py @@ -0,0 +1,395 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: MIT +""" +xlsx_add_column.py — Add a new column to a worksheet in an unpacked xlsx. + +Usage examples: + # Add a percentage column with formulas and number format + python3 xlsx_add_column.py /tmp/work/ --col G \\ + --sheet "Budget FY2025" \\ + --header "% of Total" \\ + --formula '=F{row}/$F$10' --formula-rows 2:9 \\ + --total-row 10 --total-formula '=SUM(G2:G9)' \\ + --numfmt '0.0%' + +What it does: + 1. Adds header cell (copies style from previous column's header) + 2. Adds formula cells for the specified row range + 3. Adds a total formula cell if specified + 4. Creates a new cell style with the given numfmt if needed + 5. Updates sharedStrings.xml for header text + 6. Updates dimension ref and column definitions + +IMPORTANT: Run on an UNPACKED directory (from xlsx_unpack.py). +After running, repack with xlsx_pack.py. +""" + +import argparse +import copy +import os +import re +import sys +import xml.dom.minidom +import xml.etree.ElementTree as ET + +NS_SS = "http://schemas.openxmlformats.org/spreadsheetml/2006/main" +NS_REL = "http://schemas.openxmlformats.org/officeDocument/2006/relationships" + +ET.register_namespace('', NS_SS) +ET.register_namespace('r', NS_REL) +ET.register_namespace('xdr', 'http://schemas.openxmlformats.org/drawingml/2006/spreadsheetDrawing') +ET.register_namespace('x14', 'http://schemas.microsoft.com/office/spreadsheetml/2009/9/main') +ET.register_namespace('xr2', 'http://schemas.microsoft.com/office/spreadsheetml/2015/revision2') +ET.register_namespace('mc', 'http://schemas.openxmlformats.org/markup-compatibility/2006') + + +def _tag(local: str) -> str: + return f"{{{NS_SS}}}{local}" + + +def _write_tree(tree: ET.ElementTree, path: str) -> None: + tree.write(path, encoding="unicode", xml_declaration=False) + with open(path, "r", encoding="utf-8") as fh: + raw = fh.read() + try: + dom = xml.dom.minidom.parseString(raw.encode("utf-8")) + pretty = dom.toprettyxml(indent=" ", encoding="utf-8").decode("utf-8") + lines = [line for line in pretty.splitlines() if line.strip()] + with open(path, "w", encoding="utf-8") as fh: + fh.write("\n".join(lines) + "\n") + except Exception: + pass + + +def col_number(s: str) -> int: + n = 0 + for c in s.upper(): + n = n * 26 + (ord(c) - 64) + return n + + +def col_letter(n: int) -> str: + r = "" + while n > 0: + n, rem = divmod(n - 1, 26) + r = chr(65 + rem) + r + return r + + +def find_ws_path(work_dir: str, sheet_name: str | None) -> str: + wb_tree = ET.parse(os.path.join(work_dir, "xl", "workbook.xml")) + rid = None + for sheet in wb_tree.getroot().iter(_tag("sheet")): + if sheet_name is None or sheet.get("name") == sheet_name: + rid = sheet.get(f"{{{NS_REL}}}id") + break + + if rid is None: + print(f"ERROR: Sheet not found: {sheet_name}") + sys.exit(1) + + rels_tree = ET.parse(os.path.join(work_dir, "xl", "_rels", "workbook.xml.rels")) + for rel in rels_tree.getroot(): + if rel.get("Id") == rid: + return os.path.join(work_dir, "xl", rel.get("Target")) + + print(f"ERROR: Relationship not found: {rid}") + sys.exit(1) + + +def add_shared_string(work_dir: str, text: str) -> int: + ss_path = os.path.join(work_dir, "xl", "sharedStrings.xml") + tree = ET.parse(ss_path) + root = tree.getroot() + + idx = 0 + for si in root.findall(_tag("si")): + t_el = si.find(_tag("t")) + if t_el is not None and t_el.text == text: + return idx + idx += 1 + + si = ET.SubElement(root, _tag("si")) + t = ET.SubElement(si, _tag("t")) + t.set("{http://www.w3.org/XML/1998/namespace}space", "preserve") + t.text = text + + root.set("count", str(int(root.get("count", "0")) + 1)) + root.set("uniqueCount", str(int(root.get("uniqueCount", "0")) + 1)) + + _write_tree(tree, ss_path) + return idx + + +def get_cell_style(ws_tree: ET.ElementTree, col: str, row: int) -> int: + ref = f"{col}{row}" + for row_el in ws_tree.getroot().iter(_tag("row")): + if row_el.get("r") == str(row): + for c in row_el: + if c.get("r") == ref: + return int(c.get("s", "0")) + return 0 + + +def ensure_numfmt_style(work_dir: str, ref_style_idx: int, numfmt_code: str) -> int: + """Clone a cellXfs entry with the given numfmt. Returns new style index.""" + styles_path = os.path.join(work_dir, "xl", "styles.xml") + tree = ET.parse(styles_path) + root = tree.getroot() + + # Find or add numFmt + numfmts = root.find(_tag("numFmts")) + numfmt_id = None + if numfmts is not None: + for nf in numfmts: + if nf.get("formatCode") == numfmt_code: + numfmt_id = int(nf.get("numFmtId")) + break + + if numfmt_id is None: + max_id = 163 + if numfmts is not None: + for nf in numfmts: + max_id = max(max_id, int(nf.get("numFmtId", "0"))) + else: + numfmts = ET.SubElement(root, _tag("numFmts")) + numfmts.set("count", "0") + root.remove(numfmts) + root.insert(0, numfmts) + + numfmt_id = max_id + 1 + nf = ET.SubElement(numfmts, _tag("numFmt")) + nf.set("numFmtId", str(numfmt_id)) + nf.set("formatCode", numfmt_code) + numfmts.set("count", str(len(list(numfmts)))) + + # Find or create cellXfs entry + cellxfs = root.find(_tag("cellXfs")) + xf_list = list(cellxfs) + ref_xf = xf_list[min(ref_style_idx, len(xf_list) - 1)] + + for i, xf in enumerate(xf_list): + if (xf.get("numFmtId") == str(numfmt_id) and + xf.get("fontId") == ref_xf.get("fontId") and + xf.get("fillId") == ref_xf.get("fillId") and + xf.get("borderId") == ref_xf.get("borderId")): + return i + + new_xf = copy.deepcopy(ref_xf) + new_xf.set("numFmtId", str(numfmt_id)) + new_xf.set("applyNumberFormat", "true") + cellxfs.append(new_xf) + cellxfs.set("count", str(len(list(cellxfs)))) + + _write_tree(tree, styles_path) + return len(list(cellxfs)) - 1 + + +def _apply_border_to_row(work_dir: str, ws_path: str, ws_tree: ET.ElementTree, + ws_root: ET.Element, row_map: dict, border_row: int, + border_style: str, new_col: str) -> None: + """Apply a top border to ALL cells in the specified row (A through new_col).""" + styles_path = os.path.join(work_dir, "xl", "styles.xml") + st_tree = ET.parse(styles_path) + st_root = st_tree.getroot() + + # 1. Create a new border entry with the specified top style + borders = st_root.find(_tag("borders")) + new_border = ET.SubElement(borders, _tag("border")) + for side in ("left", "right"): + ET.SubElement(new_border, _tag(side)) + top_el = ET.SubElement(new_border, _tag("top")) + top_el.set("style", border_style) + ET.SubElement(new_border, _tag("bottom")) + ET.SubElement(new_border, _tag("diagonal")) + borders.set("count", str(len(list(borders)))) + new_border_id = len(list(borders)) - 1 + + # 2. For each existing style used in the row, create a clone with the new borderId + cellxfs = st_root.find(_tag("cellXfs")) + style_remap = {} # old_style_idx -> new_style_idx + + if border_row not in row_map: + return + + row_el = row_map[border_row] + # Collect all cells in this row and their styles + for c in row_el: + old_s = int(c.get("s", "0")) + if old_s not in style_remap: + xf_list = list(cellxfs) + ref_xf = xf_list[min(old_s, len(xf_list) - 1)] + new_xf = copy.deepcopy(ref_xf) + new_xf.set("borderId", str(new_border_id)) + new_xf.set("applyBorder", "true") + cellxfs.append(new_xf) + cellxfs.set("count", str(len(list(cellxfs)))) + style_remap[old_s] = len(list(cellxfs)) - 1 + + # 3. Apply remapped styles to all cells in the row + for c in row_el: + old_s = int(c.get("s", "0")) + if old_s in style_remap: + c.set("s", str(style_remap[old_s])) + + _write_tree(st_tree, styles_path) + last_col_num = col_number(new_col) + print(f" Applied {border_style} top border to all cells in row {border_row} " + f"(A-{new_col}, {len(style_remap)} style(s) cloned)") + + +def main() -> None: + parser = argparse.ArgumentParser( + description="Add a column to a worksheet in an unpacked xlsx") + parser.add_argument("work_dir", help="Unpacked xlsx working directory") + parser.add_argument("--col", required=True, help="Column letter (e.g., G)") + parser.add_argument("--sheet", default=None, help="Sheet name (default: first)") + parser.add_argument("--header", default=None, help="Header text for row 1") + parser.add_argument("--formula", default=None, + help="Formula template with {row} placeholder") + parser.add_argument("--formula-rows", default=None, + help="Row range for formulas (e.g., 2:9)") + parser.add_argument("--total-row", type=int, default=None, + help="Row number for total formula") + parser.add_argument("--total-formula", default=None, + help="Formula for total row") + parser.add_argument("--numfmt", default=None, + help="Number format for data/total cells (e.g., 0.0%%)") + parser.add_argument("--border-row", type=int, default=None, + help="Row to apply a top border to ALL cells (e.g., 10)") + parser.add_argument("--border-style", default="medium", + help="Border style: thin, medium, thick (default: medium)") + args = parser.parse_args() + + col = args.col.upper() + prev_col = col_letter(col_number(col) - 1) if col_number(col) > 1 else "A" + + ws_path = find_ws_path(args.work_dir, args.sheet) + ws_tree = ET.parse(ws_path) + changes = 0 + + print(f"Adding column {col} to {os.path.basename(ws_path)}") + + # Resolve styles from previous column + header_style = get_cell_style(ws_tree, prev_col, 1) if args.header else 0 + + data_style = None + if args.formula_rows: + start_row = int(args.formula_rows.split(":")[0]) + ref = get_cell_style(ws_tree, prev_col, start_row) + data_style = (ensure_numfmt_style(args.work_dir, ref, args.numfmt) + if args.numfmt else ref) + + total_style = None + if args.total_row: + ref = get_cell_style(ws_tree, prev_col, args.total_row) + total_style = (ensure_numfmt_style(args.work_dir, ref, args.numfmt) + if args.numfmt else ref) + + # Add header to sharedStrings + header_idx = add_shared_string(args.work_dir, args.header) if args.header else None + + # Re-parse worksheet (sharedStrings write may have changed state) + ws_tree = ET.parse(ws_path) + root = ws_tree.getroot() + sheet_data = root.find(_tag("sheetData")) + + row_map = {} + for row_el in sheet_data: + r = row_el.get("r") + if r: + row_map[int(r)] = row_el + + # Add header cell + if args.header and 1 in row_map: + cell = ET.SubElement(row_map[1], _tag("c")) + cell.set("r", f"{col}1") + cell.set("s", str(header_style)) + cell.set("t", "s") + v = ET.SubElement(cell, _tag("v")) + v.text = str(header_idx) + changes += 1 + print(f" {col}1 = \"{args.header}\" (header, style={header_style})") + + # Add formula cells + if args.formula and args.formula_rows: + start, end = map(int, args.formula_rows.split(":")) + for row_num in range(start, end + 1): + if row_num not in row_map: + row_el = ET.SubElement(sheet_data, _tag("row")) + row_el.set("r", str(row_num)) + row_map[row_num] = row_el + + formula_text = args.formula.replace("{row}", str(row_num)) + formula_text = formula_text.lstrip("=") + cell = ET.SubElement(row_map[row_num], _tag("c")) + cell.set("r", f"{col}{row_num}") + if data_style is not None: + cell.set("s", str(data_style)) + f_el = ET.SubElement(cell, _tag("f")) + f_el.text = formula_text + changes += 1 + + print(f" {col}{start}:{col}{end} = formulas (style={data_style})") + + # Add total formula + if args.total_row and args.total_formula: + if args.total_row not in row_map: + row_el = ET.SubElement(sheet_data, _tag("row")) + row_el.set("r", str(args.total_row)) + row_map[args.total_row] = row_el + + total_f = args.total_formula.lstrip("=") + cell = ET.SubElement(row_map[args.total_row], _tag("c")) + cell.set("r", f"{col}{args.total_row}") + if total_style is not None: + cell.set("s", str(total_style)) + f_el = ET.SubElement(cell, _tag("f")) + f_el.text = total_f + changes += 1 + print(f" {col}{args.total_row} = ={total_f} (style={total_style})") + + # Update dimension + for dim in root.iter(_tag("dimension")): + old_ref = dim.get("ref", "") + if ":" in old_ref: + start_ref, end_ref = old_ref.split(":") + end_col_str = re.match(r"([A-Z]+)", end_ref).group(1) + end_row_str = re.search(r"(\d+)", end_ref).group(1) + if col_number(col) > col_number(end_col_str): + new_ref = f"{start_ref}:{col}{end_row_str}" + dim.set("ref", new_ref) + print(f" Dimension: {old_ref} → {new_ref}") + + # Extend to cover new column + cols_el = root.find(_tag("cols")) + if cols_el is not None: + new_col_num = col_number(col) + covered = any( + int(c.get("min", "0")) <= new_col_num <= int(c.get("max", "0")) + for c in cols_el + ) + if not covered: + prev_num = col_number(prev_col) + for c in cols_el: + if int(c.get("min", "0")) <= prev_num <= int(c.get("max", "0")): + new_col_def = copy.deepcopy(c) + new_col_def.set("min", str(new_col_num)) + new_col_def.set("max", str(new_col_num)) + cols_el.append(new_col_def) + print(f" Added definition for column {col}") + break + + # Apply border to entire row if requested + if args.border_row: + _apply_border_to_row(args.work_dir, ws_path, ws_tree, root, + row_map, args.border_row, args.border_style, + col) + + _write_tree(ws_tree, ws_path) + print(f"\nDone. {changes} cells added.") + print(f"\nNext: python3 xlsx_pack.py {args.work_dir} output.xlsx") + + +if __name__ == "__main__": + main() diff --git a/backend/app/skills_builtin/minimax-xlsx/scripts/xlsx_insert_row.py b/backend/app/skills_builtin/minimax-xlsx/scripts/xlsx_insert_row.py new file mode 100644 index 0000000..9dc5d6e --- /dev/null +++ b/backend/app/skills_builtin/minimax-xlsx/scripts/xlsx_insert_row.py @@ -0,0 +1,274 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: MIT +""" +xlsx_insert_row.py — Insert a new data row into a worksheet in an unpacked xlsx. + +Usage examples: + # Insert "Utilities" row at position 6, copying styles from row 5 + python3 xlsx_insert_row.py /tmp/work/ --at 6 \\ + --sheet "Budget FY2025" \\ + --text A=Utilities \\ + --values B=3000 C=3000 D=3500 E=3500 \\ + --formula 'F=SUM(B{row}:E{row})' \\ + --copy-style-from 5 + +What it does: + 1. Shifts all rows >= at down by 1 (calls xlsx_shift_rows.py) + 2. Adds text values to sharedStrings.xml + 3. Inserts new row with specified cells (text, numbers, formulas) + 4. Copies cell styles from a reference row + 5. Updates dimension ref + +The shift operation automatically expands SUM formulas that span the +insertion point, so total-row formulas are updated without extra work. + +IMPORTANT: Run on an UNPACKED directory (from xlsx_unpack.py). +After running, repack with xlsx_pack.py. +""" + +import argparse +import os +import re +import subprocess +import sys +import xml.dom.minidom +import xml.etree.ElementTree as ET + +NS_SS = "http://schemas.openxmlformats.org/spreadsheetml/2006/main" +NS_REL = "http://schemas.openxmlformats.org/officeDocument/2006/relationships" + +ET.register_namespace('', NS_SS) +ET.register_namespace('r', NS_REL) +ET.register_namespace('xdr', 'http://schemas.openxmlformats.org/drawingml/2006/spreadsheetDrawing') +ET.register_namespace('x14', 'http://schemas.microsoft.com/office/spreadsheetml/2009/9/main') +ET.register_namespace('xr2', 'http://schemas.microsoft.com/office/spreadsheetml/2015/revision2') +ET.register_namespace('mc', 'http://schemas.openxmlformats.org/markup-compatibility/2006') + + +def _tag(local: str) -> str: + return f"{{{NS_SS}}}{local}" + + +def _write_tree(tree: ET.ElementTree, path: str) -> None: + tree.write(path, encoding="unicode", xml_declaration=False) + with open(path, "r", encoding="utf-8") as fh: + raw = fh.read() + try: + dom = xml.dom.minidom.parseString(raw.encode("utf-8")) + pretty = dom.toprettyxml(indent=" ", encoding="utf-8").decode("utf-8") + lines = [line for line in pretty.splitlines() if line.strip()] + with open(path, "w", encoding="utf-8") as fh: + fh.write("\n".join(lines) + "\n") + except Exception: + pass + + +def col_number(s: str) -> int: + n = 0 + for c in s.upper(): + n = n * 26 + (ord(c) - 64) + return n + + +def find_ws_path(work_dir: str, sheet_name: str | None) -> str: + wb_tree = ET.parse(os.path.join(work_dir, "xl", "workbook.xml")) + rid = None + for sheet in wb_tree.getroot().iter(_tag("sheet")): + if sheet_name is None or sheet.get("name") == sheet_name: + rid = sheet.get(f"{{{NS_REL}}}id") + break + + if rid is None: + print(f"ERROR: Sheet not found: {sheet_name}") + sys.exit(1) + + rels_tree = ET.parse(os.path.join(work_dir, "xl", "_rels", "workbook.xml.rels")) + for rel in rels_tree.getroot(): + if rel.get("Id") == rid: + return os.path.join(work_dir, "xl", rel.get("Target")) + + print(f"ERROR: Relationship not found: {rid}") + sys.exit(1) + + +def add_shared_string(work_dir: str, text: str) -> int: + ss_path = os.path.join(work_dir, "xl", "sharedStrings.xml") + tree = ET.parse(ss_path) + root = tree.getroot() + + idx = 0 + for si in root.findall(_tag("si")): + t_el = si.find(_tag("t")) + if t_el is not None and t_el.text == text: + return idx + idx += 1 + + si = ET.SubElement(root, _tag("si")) + t = ET.SubElement(si, _tag("t")) + t.set("{http://www.w3.org/XML/1998/namespace}space", "preserve") + t.text = text + + root.set("count", str(int(root.get("count", "0")) + 1)) + root.set("uniqueCount", str(int(root.get("uniqueCount", "0")) + 1)) + + _write_tree(tree, ss_path) + return idx + + +def get_row_styles(ws_tree: ET.ElementTree, row_num: int) -> dict[str, int]: + """Get {col_letter: style_index} for all cells in a row.""" + styles = {} + for row_el in ws_tree.getroot().iter(_tag("row")): + if row_el.get("r") == str(row_num): + for c in row_el: + ref = c.get("r", "") + col_str = re.match(r"([A-Z]+)", ref) + if col_str: + styles[col_str.group(1)] = int(c.get("s", "0")) + break + return styles + + +def parse_kv(specs: list[str] | None) -> dict[str, str]: + if not specs: + return {} + result = {} + for spec in specs: + col, _, val = spec.partition("=") + result[col.upper()] = val + return result + + +def main() -> None: + parser = argparse.ArgumentParser( + description="Insert a new row into a worksheet in an unpacked xlsx") + parser.add_argument("work_dir", help="Unpacked xlsx working directory") + parser.add_argument("--at", type=int, required=True, + help="Row number to insert at (existing rows shift down)") + parser.add_argument("--sheet", default=None, help="Sheet name (default: first)") + parser.add_argument("--text", nargs="+", default=None, + help="Text cells: COL=VALUE (e.g., A=Utilities)") + parser.add_argument("--values", nargs="+", default=None, + help="Numeric cells: COL=VALUE (e.g., B=3000 C=3000)") + parser.add_argument("--formula", nargs="+", default=None, + help="Formula cells: COL=FORMULA with {row} (e.g., F=SUM(B{row}:E{row}))") + parser.add_argument("--copy-style-from", type=int, default=None, + help="Copy cell styles from this row number") + args = parser.parse_args() + + at = args.at + text_cells = parse_kv(args.text) + num_cells = parse_kv(args.values) + formula_cells = parse_kv(args.formula) + + # Step 1: Shift rows down using xlsx_shift_rows.py + script_dir = os.path.dirname(os.path.abspath(__file__)) + shift_script = os.path.join(script_dir, "xlsx_shift_rows.py") + + print(f"Step 1: Shifting rows >= {at} down by 1...") + result = subprocess.run( + [sys.executable, shift_script, args.work_dir, "insert", str(at), "1"], + capture_output=True, text=True, + ) + if result.returncode != 0: + print(f"ERROR: shift_rows failed:\n{result.stderr}") + sys.exit(1) + print(result.stdout) + + # Step 2: Resolve worksheet path and get reference styles + ws_path = find_ws_path(args.work_dir, args.sheet) + ws_tree = ET.parse(ws_path) + + ref_styles = {} + if args.copy_style_from is not None: + ref_styles = get_row_styles(ws_tree, args.copy_style_from) + print(f"Step 2: Copied styles from row {args.copy_style_from}: {ref_styles}") + + # Step 3: Add text values to sharedStrings + text_indices = {} + for col, text in text_cells.items(): + text_indices[col] = add_shared_string(args.work_dir, text) + print(f" Added shared string: \"{text}\" → index {text_indices[col]}") + + # Step 4: Re-parse worksheet and build new row + ws_tree = ET.parse(ws_path) + root = ws_tree.getroot() + sheet_data = root.find(_tag("sheetData")) + + new_row = ET.Element(_tag("row")) + new_row.set("r", str(at)) + + all_cols = sorted( + set(list(text_cells) + list(num_cells) + list(formula_cells)), + key=col_number, + ) + + for col in all_cols: + cell = ET.SubElement(new_row, _tag("c")) + cell.set("r", f"{col}{at}") + + if col in ref_styles: + cell.set("s", str(ref_styles[col])) + + if col in text_cells: + cell.set("t", "s") + v = ET.SubElement(cell, _tag("v")) + v.text = str(text_indices[col]) + elif col in num_cells: + # Omit t attribute for numbers — "n" is the default per OOXML spec + v = ET.SubElement(cell, _tag("v")) + v.text = str(num_cells[col]) + elif col in formula_cells: + formula_text = formula_cells[col].replace("{row}", str(at)).lstrip("=") + f_el = ET.SubElement(cell, _tag("f")) + f_el.text = formula_text + # Use formula style from reference if available; it may differ + # from the data style (e.g., black font vs blue font). + # Look for the formula column's style specifically. + if col in ref_styles: + cell.set("s", str(ref_styles[col])) + + # Insert new row at the correct position in sheetData (sorted by row number) + insert_idx = 0 + for i, row_el in enumerate(list(sheet_data)): + r = row_el.get("r") + if r and int(r) > at: + insert_idx = i + break + insert_idx = i + 1 + + sheet_data.insert(insert_idx, new_row) + + print(f"\nStep 3: Inserted row {at} with {len(all_cols)} cells:") + for col in all_cols: + if col in text_cells: + print(f" {col}{at} = \"{text_cells[col]}\" (text)") + elif col in num_cells: + print(f" {col}{at} = {num_cells[col]} (number)") + elif col in formula_cells: + ftext = formula_cells[col].replace("{row}", str(at)) + print(f" {col}{at} = {ftext} (formula)") + + # Step 5: Update dimension + for dim in root.iter(_tag("dimension")): + old_ref = dim.get("ref", "") + if ":" in old_ref: + start_ref, end_ref = old_ref.split(":") + end_row = int(re.search(r"(\d+)", end_ref).group(1)) + end_col = re.match(r"([A-Z]+)", end_ref).group(1) + # Dimension was already shifted by shift_rows, just verify + max_col = max(col_number(end_col), max(col_number(c) for c in all_cols)) + max_col_letter = end_col if col_number(end_col) >= max_col else col + new_ref = f"{start_ref}:{max_col_letter}{end_row}" + if new_ref != old_ref: + dim.set("ref", new_ref) + print(f"\n Dimension: {old_ref} → {new_ref}") + + _write_tree(ws_tree, ws_path) + + print(f"\nDone. Row {at} inserted successfully.") + print(f"\nNext: python3 xlsx_pack.py {args.work_dir} output.xlsx") + + +if __name__ == "__main__": + main() diff --git a/backend/app/skills_builtin/minimax-xlsx/scripts/xlsx_pack.py b/backend/app/skills_builtin/minimax-xlsx/scripts/xlsx_pack.py new file mode 100644 index 0000000..41bc208 --- /dev/null +++ b/backend/app/skills_builtin/minimax-xlsx/scripts/xlsx_pack.py @@ -0,0 +1,87 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: MIT +""" +xlsx_pack.py — Pack a working directory back into a valid xlsx file. + +Usage: + python3 xlsx_pack.py + +Requirements: + - source_dir must contain [Content_Types].xml at its root + - All XML files are re-validated for well-formedness before packing + +The resulting xlsx is a valid ZIP archive with correct OOXML structure. +""" + +import sys +import os +import zipfile +import xml.etree.ElementTree as ET + + +def validate_xml_files(source_dir: str) -> list[str]: + """Return list of XML files that fail to parse.""" + bad = [] + for dirpath, _, filenames in os.walk(source_dir): + for fname in filenames: + if fname.endswith(".xml") or fname.endswith(".rels"): + fpath = os.path.join(dirpath, fname) + try: + ET.parse(fpath) + except ET.ParseError as e: + rel = os.path.relpath(fpath, source_dir) + bad.append(f"{rel}: {e}") + return bad + + +def pack(source_dir: str, xlsx_path: str) -> None: + if not os.path.isdir(source_dir): + print(f"ERROR: Directory not found: {source_dir}", file=sys.stderr) + sys.exit(1) + + content_types = os.path.join(source_dir, "[Content_Types].xml") + if not os.path.isfile(content_types): + print( + f"ERROR: Missing [Content_Types].xml in {source_dir}\n" + " This file is required at the root of every valid xlsx package.", + file=sys.stderr, + ) + sys.exit(1) + + # Validate XML well-formedness before packing + print("Validating XML files...") + bad_files = validate_xml_files(source_dir) + if bad_files: + print("ERROR: The following files have XML parse errors:", file=sys.stderr) + for b in bad_files: + print(f" {b}", file=sys.stderr) + print( + "\nFix all XML errors before packing. " + "A malformed xlsx cannot be opened by Excel or LibreOffice.", + file=sys.stderr, + ) + sys.exit(1) + + print("✓ All XML files are well-formed") + + # Count files to pack + file_count = sum(len(files) for _, _, files in os.walk(source_dir)) + + with zipfile.ZipFile(xlsx_path, "w", compression=zipfile.ZIP_DEFLATED) as z: + for dirpath, _, filenames in os.walk(source_dir): + for fname in filenames: + fpath = os.path.join(dirpath, fname) + arcname = os.path.relpath(fpath, source_dir) + z.write(fpath, arcname) + + size = os.path.getsize(xlsx_path) + print(f"Packed {file_count} files → '{xlsx_path}' ({size:,} bytes)") + print("\nNext step: run formula_check.py to validate formulas:") + print(f" python3 formula_check.py {xlsx_path}") + + +if __name__ == "__main__": + if len(sys.argv) != 3: + print("Usage: xlsx_pack.py ") + sys.exit(1) + pack(sys.argv[1], sys.argv[2]) diff --git a/backend/app/skills_builtin/minimax-xlsx/scripts/xlsx_reader.py b/backend/app/skills_builtin/minimax-xlsx/scripts/xlsx_reader.py new file mode 100644 index 0000000..00e3432 --- /dev/null +++ b/backend/app/skills_builtin/minimax-xlsx/scripts/xlsx_reader.py @@ -0,0 +1,362 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: MIT +""" +xlsx_reader.py — Structure discovery and data analysis tool for Excel/CSV files. + +Usage: + python3 xlsx_reader.py # full structure report + python3 xlsx_reader.py --sheet Sales # analyze one sheet + python3 xlsx_reader.py --json # machine-readable output + python3 xlsx_reader.py --quality # data quality audit only + +Supports: .xlsx, .xlsm, .csv, .tsv +Does NOT modify the source file in any way. + +Exit codes: + 0 — success + 1 — file not found / unsupported format / encoding failure +""" + +import sys +import json +import argparse +from pathlib import Path + + +# --------------------------------------------------------------------------- +# Format detection and loading +# --------------------------------------------------------------------------- + +def detect_and_load(file_path: str, sheet_name_filter: str | None = None) -> dict: + """ + Load file into {sheet_name: DataFrame} dict. + CSV/TSV files are mapped to a single-key dict using the file stem as key. + + Raises ValueError for unsupported formats or encoding failures. + """ + try: + import pandas as pd + except ImportError: + raise RuntimeError( + "pandas is not installed. Run: pip install pandas openpyxl" + ) + + path = Path(file_path) + if not path.exists(): + raise FileNotFoundError(f"File not found: {file_path}") + + suffix = path.suffix.lower() + + if suffix in (".xlsx", ".xlsm"): + target = sheet_name_filter if sheet_name_filter else None + result = pd.read_excel(file_path, sheet_name=target) + # pd.read_excel with sheet_name=None returns dict; with a name, returns DataFrame + if isinstance(result, dict): + return result + else: + return {sheet_name_filter: result} + + elif suffix in (".csv", ".tsv"): + sep = "\t" if suffix == ".tsv" else "," + encodings = ["utf-8-sig", "gbk", "utf-8", "latin-1"] + last_error = None + for enc in encodings: + try: + import pandas as pd + df = pd.read_csv(file_path, sep=sep, encoding=enc) + df._reader_encoding = enc # attach metadata (non-standard, for reporting) + return {path.stem: df} + except (UnicodeDecodeError, Exception) as e: + last_error = e + continue + raise ValueError( + f"Cannot decode {file_path}. Tried encodings: {encodings}. " + f"Last error: {last_error}" + ) + + elif suffix == ".xls": + raise ValueError( + ".xls is a legacy binary format not supported by this tool. " + "Please open the file in Excel and save as .xlsx, then retry." + ) + + else: + raise ValueError( + f"Unsupported file format: {suffix}. " + "Supported formats: .xlsx, .xlsm, .csv, .tsv" + ) + + +# --------------------------------------------------------------------------- +# Structure discovery +# --------------------------------------------------------------------------- + +def explore_structure(sheets: dict) -> dict: + """ + Return a structured dict describing each sheet. + Keys: sheet_name -> {shape, columns, dtypes, null_counts, preview} + """ + result = {} + for sheet_name, df in sheets.items(): + null_counts = df.isnull().sum() + null_info = { + col: {"count": int(cnt), "pct": round(cnt / max(len(df), 1) * 100, 1)} + for col, cnt in null_counts.items() + if cnt > 0 + } + result[sheet_name] = { + "shape": {"rows": df.shape[0], "cols": df.shape[1]}, + "columns": list(df.columns), + "dtypes": {col: str(dtype) for col, dtype in df.dtypes.items()}, + "null_columns": null_info, + "preview": df.head(5).to_dict(orient="records"), + } + return result + + +# --------------------------------------------------------------------------- +# Data quality audit +# --------------------------------------------------------------------------- + +def audit_quality(sheets: dict) -> dict: + """ + Return data quality findings per sheet. + Checks: nulls, duplicates, mixed-type columns, potential year formatting issues. + """ + import pandas as pd + + findings = {} + for sheet_name, df in sheets.items(): + sheet_findings = [] + + # Null values + null_counts = df.isnull().sum() + for col, cnt in null_counts.items(): + if cnt > 0: + pct = round(cnt / max(len(df), 1) * 100, 1) + sheet_findings.append({ + "type": "null_values", + "column": col, + "count": int(cnt), + "pct": pct, + "note": f"Column '{col}' has {cnt} null values ({pct}%). " + "If this column contains Excel formulas, null values may " + "indicate that the formula cache has not been populated " + "(file was never opened in Excel after the formulas were written)." + }) + + # Duplicate rows + dup_count = int(df.duplicated().sum()) + if dup_count > 0: + sheet_findings.append({ + "type": "duplicate_rows", + "count": dup_count, + "note": f"{dup_count} fully duplicate rows found." + }) + + # Mixed-type object columns (numeric data stored as text) + for col in df.select_dtypes(include="object").columns: + numeric_converted = pd.to_numeric(df[col], errors="coerce") + convertible = int(numeric_converted.notna().sum()) + non_null_total = int(df[col].notna().sum()) + if 0 < convertible < non_null_total: + sheet_findings.append({ + "type": "mixed_type", + "column": col, + "convertible_to_numeric": convertible, + "non_convertible": non_null_total - convertible, + "note": f"Column '{col}' appears to contain mixed types: " + f"{convertible} values can be parsed as numbers, " + f"{non_null_total - convertible} cannot. " + "Use pd.to_numeric(df[col], errors='coerce') to unify." + }) + + # Year column formatting (e.g., 2024.0 stored as float) + for col in df.select_dtypes(include="number").columns: + col_lower = str(col).lower() + # "年" is the Chinese character for "year" — detect year columns in CJK spreadsheets + if "year" in col_lower or "yr" in col_lower or "年" in col_lower: + if df[col].dropna().between(1900, 2200).all(): + if df[col].dtype == float: + sheet_findings.append({ + "type": "year_as_float", + "column": col, + "note": f"Column '{col}' appears to be a year column stored as float " + "(e.g., 2024.0). Convert with df[col].astype(int).astype(str) " + "to get clean year strings like '2024'." + }) + + # Outliers via IQR on numeric columns + for col in df.select_dtypes(include="number").columns: + series = df[col].dropna() + if len(series) < 4: + continue + Q1, Q3 = series.quantile(0.25), series.quantile(0.75) + IQR = Q3 - Q1 + if IQR == 0: + continue + outlier_mask = (df[col] < Q1 - 1.5 * IQR) | (df[col] > Q3 + 1.5 * IQR) + outlier_count = int(outlier_mask.sum()) + if outlier_count > 0: + sheet_findings.append({ + "type": "outliers_iqr", + "column": col, + "count": outlier_count, + "note": f"Column '{col}' has {outlier_count} potential outlier(s) " + f"(outside 1.5×IQR bounds: [{Q1 - 1.5*IQR:.2f}, {Q3 + 1.5*IQR:.2f}])." + }) + + findings[sheet_name] = sheet_findings + + return findings + + +# --------------------------------------------------------------------------- +# Summary statistics +# --------------------------------------------------------------------------- + +def compute_stats(sheets: dict) -> dict: + """Compute descriptive statistics for numeric columns per sheet.""" + stats = {} + for sheet_name, df in sheets.items(): + numeric_df = df.select_dtypes(include="number") + if numeric_df.empty: + stats[sheet_name] = {} + continue + desc = numeric_df.describe().round(4) + stats[sheet_name] = desc.to_dict() + return stats + + +# --------------------------------------------------------------------------- +# Human-readable report rendering +# --------------------------------------------------------------------------- + +def render_report( + file_path: str, + structure: dict, + quality: dict, + stats: dict, +) -> str: + lines = [] + p = lines.append + + p("=" * 60) + p(f"ANALYSIS REPORT: {Path(file_path).name}") + p("=" * 60) + + # File overview + sheet_list = list(structure.keys()) + total_rows = sum(s["shape"]["rows"] for s in structure.values()) + p(f"\nSheets ({len(sheet_list)}): {', '.join(sheet_list)}") + p(f"Total rows across all sheets: {total_rows:,}") + + for sheet_name, info in structure.items(): + p(f"\n{'─' * 50}") + p(f"Sheet: {sheet_name}") + p(f"{'─' * 50}") + p(f" Size: {info['shape']['rows']:,} rows × {info['shape']['cols']} cols") + p(f" Columns: {info['columns']}") + + # Data types + p("\n Column types:") + for col, dtype in info["dtypes"].items(): + p(f" {col}: {dtype}") + + # Nulls + if info["null_columns"]: + p("\n Null values (columns with nulls only):") + for col, null_info in info["null_columns"].items(): + p(f" {col}: {null_info['count']} nulls ({null_info['pct']}%)") + else: + p("\n Null values: none") + + # Stats + sheet_stats = stats.get(sheet_name, {}) + if sheet_stats: + p("\n Numeric column statistics:") + numeric_cols = list(sheet_stats.keys()) + # Show only first 6 to keep report readable + for col in numeric_cols[:6]: + col_stats = sheet_stats[col] + p(f" {col}:") + p(f" count={col_stats.get('count', 'N/A')} " + f"mean={col_stats.get('mean', 'N/A')} " + f"min={col_stats.get('min', 'N/A')} " + f"max={col_stats.get('max', 'N/A')}") + if len(numeric_cols) > 6: + p(f" ... and {len(numeric_cols) - 6} more numeric columns") + + # Quality findings for this sheet + sheet_quality = quality.get(sheet_name, []) + if sheet_quality: + p(f"\n Data quality issues ({len(sheet_quality)} found):") + for finding in sheet_quality: + p(f" [{finding['type'].upper()}] {finding['note']}") + else: + p("\n Data quality: no issues found") + + # Preview + if info["preview"]: + p("\n Preview (first 3 rows):") + import pandas as pd + preview_df = pd.DataFrame(info["preview"][:3]) + for line in preview_df.to_string(index=False).splitlines(): + p(f" {line}") + + p("\n" + "=" * 60) + quality_issue_count = sum(len(v) for v in quality.values()) + if quality_issue_count == 0: + p("RESULT: No data quality issues detected.") + else: + p(f"RESULT: {quality_issue_count} data quality issue(s) found. See details above.") + p("=" * 60) + + return "\n".join(lines) + + +# --------------------------------------------------------------------------- +# CLI entry point +# --------------------------------------------------------------------------- + +def main() -> None: + parser = argparse.ArgumentParser( + description="Read and analyze Excel/CSV files without modifying them." + ) + parser.add_argument("file", help="Path to .xlsx, .xlsm, .csv, or .tsv file") + parser.add_argument("--sheet", help="Analyze a specific sheet only", default=None) + parser.add_argument( + "--json", action="store_true", help="Output machine-readable JSON" + ) + parser.add_argument( + "--quality", action="store_true", + help="Run data quality audit only (skip stats)" + ) + args = parser.parse_args() + + try: + sheets = detect_and_load(args.file, sheet_name_filter=args.sheet) + except (FileNotFoundError, ValueError, RuntimeError) as e: + print(f"ERROR: {e}", file=sys.stderr) + sys.exit(1) + + structure = explore_structure(sheets) + quality = audit_quality(sheets) + stats = {} if args.quality else compute_stats(sheets) + + if args.json: + output = { + "file": args.file, + "structure": structure, + "quality": quality, + "stats": stats, + } + # Convert preview records to serializable form (handle non-JSON types) + print(json.dumps(output, indent=2, ensure_ascii=False, default=str)) + else: + report = render_report(args.file, structure, quality, stats) + print(report) + + +if __name__ == "__main__": + main() diff --git a/backend/app/skills_builtin/minimax-xlsx/scripts/xlsx_shift_rows.py b/backend/app/skills_builtin/minimax-xlsx/scripts/xlsx_shift_rows.py new file mode 100644 index 0000000..5fef29d --- /dev/null +++ b/backend/app/skills_builtin/minimax-xlsx/scripts/xlsx_shift_rows.py @@ -0,0 +1,396 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: MIT +""" +xlsx_shift_rows.py — Shift all row references in an unpacked xlsx working directory +after inserting or deleting rows. + +Usage: + # Insert 2 rows at row 5 (rows 5+ shift down by 2) + python3 xlsx_shift_rows.py insert 5 2 + + # Delete 1 row at row 8 (rows 9+ shift up by 1) + python3 xlsx_shift_rows.py delete 8 1 + +What it updates in every XML file under : + - attributes in worksheet sheetData + - cell address attributes in worksheet sheetData + - formula text: absolute row references (e.g. B7, $B$7, $B7) in all sheets + - ranges + - ranges + - ranges + - extent marker + - Table
in xl/tables/*.xml + - Chart series and range references in xl/charts/*.xml + - PivotCache source in xl/pivotCaches/*.xml + +IMPORTANT: Run this script on the UNPACKED directory before repacking. +After running, repack with xlsx_pack.py and re-validate with formula_check.py. + +Limitations: + - Named ranges in workbook.xml are NOT updated automatically. + Review them manually after running this script. + - Structured table references (Table[@Column]) are NOT updated. + - External workbook links in xl/externalLinks/ are NOT updated. +""" + +import sys +import os +import re +import xml.etree.ElementTree as ET +import xml.dom.minidom + + +def col_letter(n: int) -> str: + """Convert 1-based column number to Excel column letter(s).""" + r = "" + while n > 0: + n, rem = divmod(n - 1, 26) + r = chr(65 + rem) + r + return r + + +def col_number(s: str) -> int: + """Convert Excel column letter(s) to 1-based column number.""" + n = 0 + for c in s.upper(): + n = n * 26 + (ord(c) - 64) + return n + + +# --------------------------------------------------------------------------- +# Core shifting logic for formula strings +# --------------------------------------------------------------------------- + +def _shift_refs(text: str, at: int, delta: int) -> str: + """Shift cell references in a non-quoted formula fragment.""" + def replacer(m: re.Match) -> str: + dollar_col = m.group(1) # "$" or "" + col_part = m.group(2) # e.g. "B" or "AB" + dollar_row = m.group(3) # "$" or "" + row_str = m.group(4) # e.g. "7" + row = int(row_str) + if row >= at: + row = max(1, row + delta) + return f"{dollar_col}{col_part}{dollar_row}{row}" + + pattern = r'(\$?)([A-Z]+)(\$?)(\d+)' + return re.sub(pattern, replacer, text) + + +def shift_formula(formula: str, at: int, delta: int) -> str: + """ + Shift absolute and mixed row references >= `at` by `delta` in a formula string. + + Handles: + B7 (relative col, absolute row — shifts if row >= at) + $B$7 (absolute col, absolute row — shifts) + $B7 (absolute col, relative row — shifts) + B$7 (relative col, absolute — shifts) + BUT NOT: B:B (whole-column reference — left as-is) + + Skips content inside single-quoted sheet name prefixes to avoid + corrupting names like 'Budget FY2025' (where FY2025 is NOT a cell ref). + + Does NOT handle: + - Named ranges + - Structured references (Table[@Col]) + - R1C1 notation + """ + # Split on quoted sheet names: 'Sheet Name' portions are odd-indexed + segments = re.split(r"('[^']*(?:''[^']*)*')", formula) + result = [] + for i, seg in enumerate(segments): + if i % 2 == 1: + result.append(seg) + else: + result.append(_shift_refs(seg, at, delta)) + return "".join(result) + + +def shift_sqref(sqref: str, at: int, delta: int) -> str: + """ + Shift row references in a sqref string (space-separated cell/range addresses). + E.g. "A5:D20 B30" → shift rows >= 5 by delta. + """ + parts = sqref.split() + result = [] + for part in parts: + if ':' in part: + left, right = part.split(':', 1) + left = shift_formula(left, at, delta) + right = shift_formula(right, at, delta) + result.append(f"{left}:{right}") + else: + result.append(shift_formula(part, at, delta)) + return " ".join(result) + + +def shift_chart_range(text: str, at: int, delta: int) -> str: + """ + Shift row references inside a chart range formula like: + Sheet1!$B$5:$B$20 + 'Q1 Data'!$A$3:$A$15 + """ + # Split on the "!" to preserve sheet name + if '!' not in text: + return text + bang = text.index('!') + sheet_part = text[:bang + 1] + range_part = text[bang + 1:] + return sheet_part + shift_formula(range_part, at, delta) + + +# --------------------------------------------------------------------------- +# XML file processors +# --------------------------------------------------------------------------- + +NS_MAIN = "http://schemas.openxmlformats.org/spreadsheetml/2006/main" +NS_DRAWING = "http://schemas.openxmlformats.org/drawingml/2006/chartDrawing" + +# Namespace map used by ElementTree for tag lookup +NSMAP = {"ss": NS_MAIN} + + +def _tag(local: str) -> str: + return f"{{{NS_MAIN}}}{local}" + + +def process_worksheet(path: str, at: int, delta: int) -> int: + """Update row/cell references in a worksheet XML. Returns change count.""" + tree = ET.parse(path) + root = tree.getroot() + changes = 0 + + # 1. + for dim in root.iter(_tag("dimension")): + old = dim.get("ref", "") + new = shift_sqref(old, at, delta) + if new != old: + dim.set("ref", new) + changes += 1 + + # 2. and inside sheetData + sheet_data = root.find(_tag("sheetData")) + if sheet_data is not None: + rows_to_reorder = [] + for row_el in list(sheet_data): + r_str = row_el.get("r") + if r_str is None: + continue + r = int(r_str) + if r >= at: + new_r = max(1, r + delta) + row_el.set("r", str(new_r)) + changes += 1 + # Update each cell's r attribute + for cell_el in row_el: + cell_ref = cell_el.get("r", "") + if cell_ref: + new_ref = shift_formula(cell_ref, at, delta) + if new_ref != cell_ref: + cell_el.set("r", new_ref) + changes += 1 + + # Also update formulas in every row (formulas can reference any row) + for cell_el in row_el: + f_el = cell_el.find(_tag("f")) + if f_el is not None and f_el.text: + new_f = shift_formula(f_el.text, at, delta) + if new_f != f_el.text: + f_el.text = new_f + changes += 1 + + # 3. + for mc in root.iter(_tag("mergeCell")): + old = mc.get("ref", "") + new = shift_sqref(old, at, delta) + if new != old: + mc.set("ref", new) + changes += 1 + + # 4. + for cf in root.iter(_tag("conditionalFormatting")): + old = cf.get("sqref", "") + new = shift_sqref(old, at, delta) + if new != old: + cf.set("sqref", new) + changes += 1 + + # 5. + for dv in root.iter(_tag("dataValidation")): + old = dv.get("sqref", "") + new = shift_sqref(old, at, delta) + if new != old: + dv.set("sqref", new) + changes += 1 + + if changes > 0: + _write_tree(tree, path) + return changes + + +def process_chart(path: str, at: int, delta: int) -> int: + """Update data range references in a chart XML.""" + # Charts use DrawingML namespace; we look for elements with range strings + with open(path, "r", encoding="utf-8") as fh: + content = fh.read() + + # Pattern matches content of Sheet1!$A$1:$A$10 style elements + def replace_f(m: re.Match) -> str: + tag_open = m.group(1) + inner = m.group(2) + tag_close = m.group(3) + new_inner = shift_chart_range(inner, at, delta) + return f"{tag_open}{new_inner}{tag_close}" + + new_content = re.sub(r'(<(?:[^:>]+:)?f>)([^<]+)(]+:)?f>)', + replace_f, content) + changes = content != new_content + if changes: + with open(path, "w", encoding="utf-8") as fh: + fh.write(new_content) + return 1 if changes else 0 + + +def process_table(path: str, at: int, delta: int) -> int: + """Update the ref attribute on the
root element.""" + tree = ET.parse(path) + root = tree.getroot() + # The root element IS the table + old = root.get("ref", "") + if not old: + return 0 + new = shift_sqref(old, at, delta) + if new == old: + return 0 + root.set("ref", new) + _write_tree(tree, path) + return 1 + + +def process_pivot_cache(path: str, at: int, delta: int) -> int: + """Update worksheetSource ref in a pivot cache definition.""" + tree = ET.parse(path) + root = tree.getroot() + changes = 0 + # Look for + for ws in root.iter(): + if ws.tag.endswith("}worksheetSource") or ws.tag == "worksheetSource": + old = ws.get("ref", "") + if old: + new = shift_sqref(old, at, delta) + if new != old: + ws.set("ref", new) + changes += 1 + if changes: + _write_tree(tree, path) + return changes + + +def _write_tree(tree: ET.ElementTree, path: str) -> None: + """Write ElementTree back to file with pretty-printing.""" + tree.write(path, encoding="unicode", xml_declaration=False) + # Re-pretty-print for readability + with open(path, "r", encoding="utf-8") as fh: + raw = fh.read() + try: + dom = xml.dom.minidom.parseString(raw.encode("utf-8")) + pretty = dom.toprettyxml(indent=" ", encoding="utf-8").decode("utf-8") + lines = [line for line in pretty.splitlines() if line.strip()] + with open(path, "w", encoding="utf-8") as fh: + fh.write("\n".join(lines) + "\n") + except Exception: + pass # If pretty-print fails, leave the file as-is + + +# --------------------------------------------------------------------------- +# Main driver +# --------------------------------------------------------------------------- + +def main() -> None: + if len(sys.argv) < 5: + print(__doc__) + sys.exit(1) + + work_dir = sys.argv[1] + operation = sys.argv[2].lower() + at = int(sys.argv[3]) + count = int(sys.argv[4]) + + if operation not in ("insert", "delete"): + print(f"ERROR: operation must be 'insert' or 'delete', got '{operation}'") + sys.exit(1) + + if operation == "insert": + delta = count + else: + delta = -count + + if not os.path.isdir(work_dir): + print(f"ERROR: Directory not found: {work_dir}") + sys.exit(1) + + print(f"Operation : {operation} {count} row(s) at row {at} (delta={delta:+d})") + print(f"Work dir : {work_dir}") + print() + + total_changes = 0 + + # Process all worksheets + ws_dir = os.path.join(work_dir, "xl", "worksheets") + if os.path.isdir(ws_dir): + for fname in sorted(os.listdir(ws_dir)): + if fname.endswith(".xml"): + fpath = os.path.join(ws_dir, fname) + n = process_worksheet(fpath, at, delta) + if n: + print(f" Updated {n:3d} references in xl/worksheets/{fname}") + total_changes += n + + # Process all charts + charts_dir = os.path.join(work_dir, "xl", "charts") + if os.path.isdir(charts_dir): + for fname in sorted(os.listdir(charts_dir)): + if fname.endswith(".xml"): + fpath = os.path.join(charts_dir, fname) + n = process_chart(fpath, at, delta) + if n: + print(f" Updated chart ranges in xl/charts/{fname}") + total_changes += n + + # Process all tables + tables_dir = os.path.join(work_dir, "xl", "tables") + if os.path.isdir(tables_dir): + for fname in sorted(os.listdir(tables_dir)): + if fname.endswith(".xml"): + fpath = os.path.join(tables_dir, fname) + n = process_table(fpath, at, delta) + if n: + print(f" Updated table ref in xl/tables/{fname}") + total_changes += n + + # Process pivot cache definitions + cache_dir = os.path.join(work_dir, "xl", "pivotCaches") + if os.path.isdir(cache_dir): + for fname in sorted(os.listdir(cache_dir)): + if "Definition" in fname and fname.endswith(".xml"): + fpath = os.path.join(cache_dir, fname) + n = process_pivot_cache(fpath, at, delta) + if n: + print(f" Updated pivot source range in xl/pivotCaches/{fname}") + total_changes += n + + print() + print(f"Total changes: {total_changes}") + print() + print("IMPORTANT: Review named ranges in xl/workbook.xml manually.") + print(" Structured table references (Table[@Col]) are NOT updated.") + print() + print("Next steps:") + print(" 1. Review the changes above") + print(f" 2. python3 xlsx_pack.py {work_dir} output.xlsx") + print(" 3. python3 formula_check.py output.xlsx") + + +if __name__ == "__main__": + main() diff --git a/backend/app/skills_builtin/minimax-xlsx/scripts/xlsx_unpack.py b/backend/app/skills_builtin/minimax-xlsx/scripts/xlsx_unpack.py new file mode 100644 index 0000000..99580ac --- /dev/null +++ b/backend/app/skills_builtin/minimax-xlsx/scripts/xlsx_unpack.py @@ -0,0 +1,130 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: MIT +""" +xlsx_unpack.py — Unpack an xlsx file into a working directory for XML editing. + +Usage: + python3 xlsx_unpack.py + +What it does: +1. Unzips the xlsx (which is a ZIP archive) +2. Pretty-prints all XML and .rels files for readability +3. Prints a summary of key files to edit +""" + +import sys +import zipfile +import os +import shutil +import xml.dom.minidom + + +def pretty_print_xml(content: bytes) -> str: + """Pretty-print XML bytes. Returns original content on parse failure.""" + try: + dom = xml.dom.minidom.parseString(content) + pretty = dom.toprettyxml(indent=" ", encoding="utf-8").decode("utf-8") + # Remove the extra blank lines toprettyxml adds + lines = [line for line in pretty.splitlines() if line.strip()] + return "\n".join(lines) + "\n" + except Exception: + return content.decode("utf-8", errors="replace") + + +def unpack(xlsx_path: str, output_dir: str) -> None: + if not os.path.isfile(xlsx_path): + print(f"ERROR: File not found: {xlsx_path}", file=sys.stderr) + sys.exit(1) + + if not xlsx_path.lower().endswith((".xlsx", ".xlsm")): + print(f"WARNING: '{xlsx_path}' does not have an .xlsx/.xlsm extension", file=sys.stderr) + + if os.path.exists(output_dir): + shutil.rmtree(output_dir) + os.makedirs(output_dir) + + try: + with zipfile.ZipFile(xlsx_path, "r") as z: + # Validate member paths to prevent zip-slip (path traversal) attacks + for member in z.namelist(): + member_path = os.path.realpath(os.path.join(output_dir, member)) + if not member_path.startswith(os.path.realpath(output_dir) + os.sep) and member_path != os.path.realpath(output_dir): + print(f"ERROR: Zip entry '{member}' would escape target directory (path traversal blocked)", file=sys.stderr) + shutil.rmtree(output_dir, ignore_errors=True) + sys.exit(1) + z.extractall(output_dir) + except zipfile.BadZipFile: + shutil.rmtree(output_dir, ignore_errors=True) + print(f"ERROR: '{xlsx_path}' is not a valid ZIP/xlsx file", file=sys.stderr) + sys.exit(1) + + # Pretty-print XML and .rels files + xml_count = 0 + for dirpath, _, filenames in os.walk(output_dir): + for fname in filenames: + if fname.endswith(".xml") or fname.endswith(".rels"): + fpath = os.path.join(dirpath, fname) + with open(fpath, "rb") as f: + raw = f.read() + pretty = pretty_print_xml(raw) + with open(fpath, "w", encoding="utf-8") as f: + f.write(pretty) + xml_count += 1 + + print(f"Unpacked '{xlsx_path}' → '{output_dir}'") + print(f"Pretty-printed {xml_count} XML/rels files\n") + + # Print key files grouped by category + categories = { + "Package root": ["[Content_Types].xml", "_rels/.rels"], + "Workbook": ["xl/workbook.xml", "xl/_rels/workbook.xml.rels"], + "Styles & Strings": ["xl/styles.xml", "xl/sharedStrings.xml"], + "Worksheets": [], + } + + all_files = [] + for dirpath, _, filenames in os.walk(output_dir): + for fname in filenames: + rel = os.path.relpath(os.path.join(dirpath, fname), output_dir) + all_files.append(rel) + + # Collect worksheets + for rel in sorted(all_files): + if rel.startswith("xl/worksheets/") and rel.endswith(".xml"): + categories["Worksheets"].append(rel) + + print("Key files to inspect/edit:") + for category, files in categories.items(): + if not files: + continue + print(f"\n [{category}]") + for f in files: + full = os.path.join(output_dir, f) + if os.path.isfile(full): + size = os.path.getsize(full) + print(f" {f} ({size:,} bytes)") + else: + print(f" {f} (not found)") + + # Warn about high-risk files present + risky = { + "xl/vbaProject.bin": "VBA macros — DO NOT modify", + "xl/pivotTables": "Pivot tables — update source ranges carefully if shifting rows", + "xl/charts": "Charts — update data ranges if shifting rows", + } + print("\n [High-risk content detected:]") + found_any = False + for path, warning in risky.items(): + full = os.path.join(output_dir, path) + if os.path.exists(full): + print(f" ⚠️ {path} — {warning}") + found_any = True + if not found_any: + print(" ✓ None (safe to edit)") + + +if __name__ == "__main__": + if len(sys.argv) != 3: + print("Usage: xlsx_unpack.py ") + sys.exit(1) + unpack(sys.argv[1], sys.argv[2]) diff --git a/backend/app/skills_builtin/minimax-xlsx/templates/minimal_xlsx/[Content_Types].xml b/backend/app/skills_builtin/minimax-xlsx/templates/minimal_xlsx/[Content_Types].xml new file mode 100644 index 0000000..956b440 --- /dev/null +++ b/backend/app/skills_builtin/minimax-xlsx/templates/minimal_xlsx/[Content_Types].xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/backend/app/skills_builtin/minimax-xlsx/templates/minimal_xlsx/_rels/.rels b/backend/app/skills_builtin/minimax-xlsx/templates/minimal_xlsx/_rels/.rels new file mode 100644 index 0000000..a0af25a --- /dev/null +++ b/backend/app/skills_builtin/minimax-xlsx/templates/minimal_xlsx/_rels/.rels @@ -0,0 +1,6 @@ + + + + diff --git a/backend/app/skills_builtin/minimax-xlsx/templates/minimal_xlsx/xl/_rels/workbook.xml.rels b/backend/app/skills_builtin/minimax-xlsx/templates/minimal_xlsx/xl/_rels/workbook.xml.rels new file mode 100644 index 0000000..9758c18 --- /dev/null +++ b/backend/app/skills_builtin/minimax-xlsx/templates/minimal_xlsx/xl/_rels/workbook.xml.rels @@ -0,0 +1,19 @@ + + + + + + + diff --git a/backend/app/skills_builtin/minimax-xlsx/templates/minimal_xlsx/xl/sharedStrings.xml b/backend/app/skills_builtin/minimax-xlsx/templates/minimal_xlsx/xl/sharedStrings.xml new file mode 100644 index 0000000..f00b424 --- /dev/null +++ b/backend/app/skills_builtin/minimax-xlsx/templates/minimal_xlsx/xl/sharedStrings.xml @@ -0,0 +1,33 @@ + + + + + diff --git a/backend/app/skills_builtin/minimax-xlsx/templates/minimal_xlsx/xl/styles.xml b/backend/app/skills_builtin/minimax-xlsx/templates/minimal_xlsx/xl/styles.xml new file mode 100644 index 0000000..21e9c67 --- /dev/null +++ b/backend/app/skills_builtin/minimax-xlsx/templates/minimal_xlsx/xl/styles.xml @@ -0,0 +1,160 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/backend/app/skills_builtin/minimax-xlsx/templates/minimal_xlsx/xl/workbook.xml b/backend/app/skills_builtin/minimax-xlsx/templates/minimal_xlsx/xl/workbook.xml new file mode 100644 index 0000000..94001d0 --- /dev/null +++ b/backend/app/skills_builtin/minimax-xlsx/templates/minimal_xlsx/xl/workbook.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + diff --git a/backend/app/skills_builtin/minimax-xlsx/templates/minimal_xlsx/xl/worksheets/sheet1.xml b/backend/app/skills_builtin/minimax-xlsx/templates/minimal_xlsx/xl/worksheets/sheet1.xml new file mode 100644 index 0000000..9c52f5c --- /dev/null +++ b/backend/app/skills_builtin/minimax-xlsx/templates/minimal_xlsx/xl/worksheets/sheet1.xml @@ -0,0 +1,70 @@ + + + + + + + + + + + + + + + + diff --git a/backend/app/skills_builtin/pptx-generator/SKILL.md b/backend/app/skills_builtin/pptx-generator/SKILL.md new file mode 100644 index 0000000..995486c --- /dev/null +++ b/backend/app/skills_builtin/pptx-generator/SKILL.md @@ -0,0 +1,249 @@ +--- +name: pptx-generator +description: "Generate, edit, and read PowerPoint presentations. Create from scratch with PptxGenJS (cover, TOC, content, section divider, summary slides), edit existing PPTX via XML workflows, or extract text with markitdown. Triggers: PPT, PPTX, PowerPoint, presentation, slide, deck, slides." +license: MIT +metadata: + version: "1.0" + category: productivity + sources: + - https://gitbrent.github.io/PptxGenJS/ + - https://github.com/microsoft/markitdown +--- + +# PPTX Generator & Editor + +## Overview + +This skill handles all PowerPoint tasks: reading/analyzing existing presentations, editing template-based decks via XML manipulation, and creating presentations from scratch using PptxGenJS. It includes a complete design system (color palettes, fonts, style recipes) and detailed guidance for every slide type. + +## Quick Reference + +| Task | Approach | +|------|----------| +| Read/analyze content | `python -m markitdown presentation.pptx` | +| Edit or create from template | See [Editing Presentations](references/editing.md) | +| Create from scratch | See [Creating from Scratch](#creating-from-scratch-workflow) below | + +| Item | Value | +|------|-------| +| **Dimensions** | 10" x 5.625" (LAYOUT_16x9) | +| **Colors** | 6-char hex without # (e.g., `"FF0000"`) | +| **English font** | Arial (default), or approved alternatives | +| **Chinese font** | Microsoft YaHei | +| **Page badge position** | x: 9.3", y: 5.1" | +| **Theme keys** | `primary`, `secondary`, `accent`, `light`, `bg` | +| **Shapes** | RECTANGLE, OVAL, LINE, ROUNDED_RECTANGLE | +| **Charts** | BAR, LINE, PIE, DOUGHNUT, SCATTER, BUBBLE, RADAR | + +## Reference Files + +| File | Contents | +|------|----------| +| [slide-types.md](references/slide-types.md) | 5 slide page types (Cover, TOC, Section Divider, Content, Summary) + additional layout patterns | +| [design-system.md](references/design-system.md) | Color palettes, font reference, style recipes (Sharp/Soft/Rounded/Pill), typography & spacing | +| [editing.md](references/editing.md) | Template-based editing workflow, XML manipulation, formatting rules, common pitfalls | +| [pitfalls.md](references/pitfalls.md) | QA process, common mistakes, critical PptxGenJS pitfalls | +| [pptxgenjs.md](references/pptxgenjs.md) | Complete PptxGenJS API reference | + +--- + +## Reading Content + +```bash +# Text extraction +python -m markitdown presentation.pptx +``` + +--- + +## Creating from Scratch — Workflow + +**Use when no template or reference presentation is available.** + +### Step 1: Research & Requirements + +Search to understand user requirements — topic, audience, purpose, tone, content depth. + +### Step 2: Select Color Palette & Fonts + +Use the [Color Palette Reference](references/design-system.md#color-palette-reference) to select a palette matching the topic and audience. Use the [Font Reference](references/design-system.md#font-reference) to choose a font pairing. + +### Step 3: Select Design Style + +Use the [Style Recipes](references/design-system.md#style-recipes) to choose a visual style (Sharp, Soft, Rounded, or Pill) matching the presentation tone. + +### Step 4: Plan Slide Outline + +Classify **every slide** as exactly one of the [5 page types](references/slide-types.md). Plan the content and layout for each slide. Ensure visual variety — do NOT repeat the same layout across slides. + +### Step 5: Generate Slide JS Files + +Create one JS file per slide in `slides/` directory. Each file must export a synchronous `createSlide(pres, theme)` function. Follow the [Slide Output Format](#slide-output-format) and the type-specific guidance in [slide-types.md](references/slide-types.md). Generate up to 5 slides concurrently using subagents if available. + +**Tell each subagent:** +1. File naming: `slides/slide-01.js`, `slides/slide-02.js`, etc. +2. Images go in: `slides/imgs/` +3. Final PPTX goes in: `slides/output/` +4. Dimensions: 10" x 5.625" (LAYOUT_16x9) +5. Fonts: Chinese = Microsoft YaHei, English = Arial (or approved alternative) +6. Colors: 6-char hex without # (e.g. `"FF0000"`) +7. Must use the theme object contract (see [Theme Object Contract](#theme-object-contract)) +8. Must follow the [PptxGenJS API reference](references/pptxgenjs.md) + +### Step 6: Compile into Final PPTX + +Create `slides/compile.js` to combine all slide modules: + +```javascript +// slides/compile.js +const pptxgen = require('pptxgenjs'); +const pres = new pptxgen(); +pres.layout = 'LAYOUT_16x9'; + +const theme = { + primary: "22223b", // dark color for backgrounds/text + secondary: "4a4e69", // secondary accent + accent: "9a8c98", // highlight color + light: "c9ada7", // light accent + bg: "f2e9e4" // background color +}; + +for (let i = 1; i <= 12; i++) { // adjust count as needed + const num = String(i).padStart(2, '0'); + const slideModule = require(`./slide-${num}.js`); + slideModule.createSlide(pres, theme); +} + +pres.writeFile({ fileName: './output/presentation.pptx' }); +``` + +Run with: `cd slides && node compile.js` + +### Step 7: QA (Required) + +See [QA Process](references/pitfalls.md#qa-process). + +### Output Structure + +``` +slides/ +├── slide-01.js # Slide modules +├── slide-02.js +├── ... +├── imgs/ # Images used in slides +└── output/ # Final artifacts + └── presentation.pptx +``` + +--- + +## Slide Output Format + +Each slide is a **complete, runnable JS file**: + +```javascript +// slide-01.js +const pptxgen = require("pptxgenjs"); + +const slideConfig = { + type: 'cover', + index: 1, + title: 'Presentation Title' +}; + +// MUST be synchronous (not async) +function createSlide(pres, theme) { + const slide = pres.addSlide(); + slide.background = { color: theme.bg }; + + slide.addText(slideConfig.title, { + x: 0.5, y: 2, w: 9, h: 1.2, + fontSize: 48, fontFace: "Arial", + color: theme.primary, bold: true, align: "center" + }); + + return slide; +} + +// Standalone preview - use slide-specific filename +if (require.main === module) { + const pres = new pptxgen(); + pres.layout = 'LAYOUT_16x9'; + const theme = { + primary: "22223b", + secondary: "4a4e69", + accent: "9a8c98", + light: "c9ada7", + bg: "f2e9e4" + }; + createSlide(pres, theme); + pres.writeFile({ fileName: "slide-01-preview.pptx" }); +} + +module.exports = { createSlide, slideConfig }; +``` + +--- + +## Theme Object Contract (MANDATORY) + +The compile script passes a theme object with these **exact keys**: + +| Key | Purpose | Example | +|-----|---------|---------| +| `theme.primary` | Darkest color, titles | `"22223b"` | +| `theme.secondary` | Dark accent, body text | `"4a4e69"` | +| `theme.accent` | Mid-tone accent | `"9a8c98"` | +| `theme.light` | Light accent | `"c9ada7"` | +| `theme.bg` | Background color | `"f2e9e4"` | + +**NEVER use other key names** like `background`, `text`, `muted`, `darkest`, `lightest`. + +--- + +## Page Number Badge (REQUIRED) + +All slides **except Cover Page** MUST include a page number badge in the bottom-right corner. + +- **Position**: x: 9.3", y: 5.1" +- Show current number only (e.g. `3` or `03`), NOT "3/12" +- Use palette colors, keep subtle + +### Circle Badge (Default) + +```javascript +slide.addShape(pres.shapes.OVAL, { + x: 9.3, y: 5.1, w: 0.4, h: 0.4, + fill: { color: theme.accent } +}); +slide.addText("3", { + x: 9.3, y: 5.1, w: 0.4, h: 0.4, + fontSize: 12, fontFace: "Arial", + color: "FFFFFF", bold: true, + align: "center", valign: "middle" +}); +``` + +### Pill Badge + +```javascript +slide.addShape(pres.shapes.ROUNDED_RECTANGLE, { + x: 9.1, y: 5.15, w: 0.6, h: 0.35, + fill: { color: theme.accent }, + rectRadius: 0.15 +}); +slide.addText("03", { + x: 9.1, y: 5.15, w: 0.6, h: 0.35, + fontSize: 11, fontFace: "Arial", + color: "FFFFFF", bold: true, + align: "center", valign: "middle" +}); +``` + +--- + +## Dependencies + +- `pip install "markitdown[pptx]"` — text extraction +- `npm install -g pptxgenjs` — creating from scratch +- `npm install -g react-icons react react-dom sharp` — icons (optional) diff --git a/backend/app/skills_builtin/pptx-generator/references/design-system.md b/backend/app/skills_builtin/pptx-generator/references/design-system.md new file mode 100644 index 0000000..ea3fc75 --- /dev/null +++ b/backend/app/skills_builtin/pptx-generator/references/design-system.md @@ -0,0 +1,392 @@ +# Design System + +## Color Palette Reference + +| # | Name | Colors | Style | Use Cases | Tips | +|---|------|--------|-------|-----------|------| +| 1 | Modern & Wellness | `#006d77` `#83c5be` `#edf6f9` `#ffddd2` `#e29578` | Fresh, soothing | Healthcare, counseling, skincare, yoga/spa | Deep teal for titles, light pink for background | +| 2 | Business & Authority | `#2b2d42` `#8d99ae` `#edf2f4` `#ef233c` `#d90429` | Formal, classic | Annual reports, financial analysis, corporate intro, government | Deep blue for professionalism, bright red to highlight data | +| 3 | Nature & Outdoors | `#606c38` `#283618` `#fefae0` `#dda15e` `#bc6c25` | Grounded, earthy | Outdoor gear, environmental, agriculture, historical culture | Dark green base, cream text | +| 4 | Vintage & Academic | `#780000` `#c1121f` `#fdf0d5` `#003049` `#669bbc` | Classic, scholarly | Academic lectures, history reviews, museums, heritage brands | Strong contrast between deep red and deep blue | +| 5 | Soft & Creative | `#cdb4db` `#ffc8dd` `#ffafcc` `#bde0fe` `#a2d2ff` | Dreamy, candy-toned | Mother & baby, desserts, women's fashion, kindergarten | Use dark gray or black for text | +| 6 | Bohemian | `#ccd5ae` `#e9edc9` `#fefae0` `#faedcd` `#d4a373` | Gentle, muted | Wedding planning, home decor, organic food, slow living | Cream background, green-brown accents | +| 7 | Vibrant & Tech | `#8ecae6` `#219ebc` `#023047` `#ffb703` `#fb8500` | High energy, sporty | Sports events, gyms, startup pitches, youth education | Deep blue for stability, orange as focal accent | +| 8 | Craft & Artisan | `#7f5539` `#a68a64` `#ede0d4` `#656d4a` `#414833` | Rustic, coffee-toned | Coffee shops, handicrafts, traditional culture, bakery | Suited for paper/leather textures | +| 9 | Tech & Night | `#000814` `#001d3d` `#003566` `#ffc300` `#ffd60a` | Deep, luminous | Tech launches, astronomy, night economy, luxury automobiles | Must use dark mode | +| 10 | Education & Charts | `#264653` `#2a9d8f` `#e9c46a` `#f4a261` `#e76f51` | Clear, logical | Statistical reports, education, market analysis, general business | Perfect chart color scheme | +| 11 | Forest & Eco | `#dad7cd` `#a3b18a` `#588157` `#3a5a40` `#344e41` | Monochrome gradient, forest | Landscape design, ESG reports, environmental causes, botanical | Monochrome palette is safe and cohesive | +| 12 | Elegant & Fashion | `#edafb8` `#f7e1d7` `#dedbd2` `#b0c4b1` `#4a5759` | Muted, Morandi tones | Haute couture, art galleries, beauty brands, magazine style | Negative space is key | +| 13 | Art & Food | `#335c67` `#fff3b0` `#e09f3e` `#9e2a2b` `#540b0e` | Rich, vintage-poster | Food documentaries, art exhibitions, ethnic themes, vintage restaurants | Works well with large color blocks | +| 14 | Luxury & Mysterious | `#22223b` `#4a4e69` `#9a8c98` `#c9ada7` `#f2e9e4` | Cool, purple-toned | Jewelry showcases, hotel management, high-end consulting, psychology | Purple evokes premium atmosphere | +| 15 | Pure Tech Blue | `#03045e` `#0077b6` `#00b4d8` `#90e0ef` `#caf0f8` | Futuristic, clean | Cloud/AI, water/ocean, hospitals, clean energy | Deep ocean to sky gradient | +| 16 | Coastal Coral | `#0081a7` `#00afb9` `#fdfcdc` `#fed9b7` `#f07167` | Refreshing, summery | Travel, summer events, beverage brands, ocean themes | Teal and coral as complementary focal colors | +| 17 | Vibrant Orange Mint | `#ff9f1c` `#ffbf69` `#ffffff` `#cbf3f0` `#2ec4b6` | Bright, cheerful | Children's events, promotional posters, FMCG, social media | Orange grabs attention, mint feels fresh | +| 18 | Platinum White Gold | `#0a0a0a` `#0070F3` `#D4AF37` `#f5f5f5` `#ffffff` | Premium, professional | Agent products, corporate websites, fintech, luxury brands | White-gold base, blue for action, gold for emphasis | + +--- + +### Agent Design System — Full Color Scale + +Based on the Platinum White-Gold Theme design tokens. Provides complete color scales for fine-grained design work. + +#### White Scale (Backgrounds & Light Surfaces) + +| Token | Value | Usage | +|-------|-------|-------| +| white-0 | `#ffffff` | Primary background | +| white-50 | `#fefefe` | Slightly warm white | +| white-75 | `#fcfcfc` | Near-white | +| white-100 | `#fafafa` | Secondary background | +| white-200 | `#f7f7f7` | Card background | +| white-300 | `#f5f5f5` | Tertiary background | +| white-400 | `#f0f0f0` | Separator zones | +| white-500 | `#ebebeb` | Light border | +| white-600 | `#e5e5e5` | Disabled background | +| white-700 | `#e0e0e0` | Deep white-gray | +| white-800 | `#d9d9d9` | Placeholder | +| white-900 | `#d4d4d4` | Divider lines | +| white-1000 | `#cccccc` | Deepest white | + +#### Gold Scale (Platinum Business Accent) + +| Token | Value | Usage | +|-------|-------|-------| +| gold-25 | `#FFFDF5` | Extremely light gold background | +| gold-50 | `#FEF9E7` | Light gold background | +| gold-75 | `#FCF3D0` | Pale gold highlight | +| gold-100 | `#FAECB8` | Gold hover state | +| gold-200 | `#F5DC8A` | Bright gold accent | +| gold-300 | `#E8C860` | Gold hover | +| gold-400 | `#D4AF37` | **Primary gold (core)** | +| gold-500 | `#B8972E` | Gold text | +| gold-600 | `#9A7E26` | Deep gold accent | +| gold-700 | `#7C651E` | Dark gold border | +| gold-800 | `#5E4C16` | Deep gold background | +| gold-900 | `#40330F` | Very deep gold | +| gold-1000 | `#221A08` | Black gold | + +#### Blue Scale (Primary Action Color) + +| Token | Value | Usage | +|-------|-------|-------| +| blue-25 | `#F0F7FF` | Extremely light blue background | +| blue-50 | `#E0EFFF` | Info alert background | +| blue-75 | `#C2DFFF` | Light blue highlight | +| blue-100 | `#A3CFFF` | Disabled blue | +| blue-200 | `#66AFFF` | Bright blue | +| blue-300 | `#338FFF` | Blue hover | +| blue-400 | `#0070F3` | **Primary blue (core)** | +| blue-500 | `#005FCC` | Blue text | +| blue-600 | `#004FA6` | Deep blue accent | +| blue-700 | `#003F80` | Dark blue border | +| blue-800 | `#002F5A` | Deep blue background | +| blue-900 | `#001F3D` | Very deep blue | +| blue-1000 | `#001026` | Black blue | + +#### Gray Scale (Text & Neutral Colors) + +| Token | Value | Usage | +|-------|-------|-------| +| gray-0 | `#ffffff` | White | +| gray-50 | `#fafafa` | Extremely light gray | +| gray-75 | `#f5f5f5` | Light gray background | +| gray-100 | `#ededed` | Light divider | +| gray-200 | `#d4d4d4` | Light border | +| gray-300 | `#a3a3a3` | Quaternary text | +| gray-400 | `#737373` | Tertiary text | +| gray-500 | `#525252` | Secondary text | +| gray-600 | `#404040` | Dark gray | +| gray-700 | `#2e2e2e` | Dark background | +| gray-800 | `#1f1f1f` | Deep background | +| gray-900 | `#141414` | Very deep background | +| gray-1000 | `#0a0a0a` | **Primary text (core)** | + +#### Opacity Values + +##### Opacity Black + +| Opacity | Value | Usage | +|---------|-------|-------| +| 0% | `#0a0a0a00` | Fully transparent | +| 2% | `#0a0a0a05` | Subtle overlay | +| 4% | `#0a0a0a0a` | Secondary interactive background | +| 8% | `#0a0a0a14` | Border / divider | +| 15% | `#0a0a0a26` | Pressed state | +| 20% | `#0a0a0a33` | Light overlay | +| 25% | `#0a0a0a40` | Medium overlay | +| 50% | `#0a0a0a80` | Semi-transparent | +| 70% | `#0a0a0ab2` | Deep overlay | +| 80% | `#0a0a0acc` | Hover state | +| 90% | `#0a0a0ae5` | Tooltip | +| 95% | `#0a0a0af2` | Modal | + +##### Opacity White + +| Opacity | Value | Usage | +|---------|-------|-------| +| 0% | `#ffffff00` | Fully transparent | +| 2% | `#ffffff05` | Subtle overlay | +| 4% | `#ffffff0a` | Secondary interactive background | +| 8% | `#ffffff12` | Border / divider | +| 15% | `#ffffff26` | Pressed state | +| 20% | `#ffffff33` | Light overlay | +| 25% | `#ffffff40` | Medium overlay | +| 50% | `#ffffff80` | Semi-transparent | +| 70% | `#ffffffb2` | Deep overlay | +| 80% | `#ffffffcc` | Hover state | +| 90% | `#ffffffe5` | Tooltip | +| 95% | `#fffffff2` | Modal | + +--- + +## Color Palette Rules (MANDATORY) + +### Strict Palette Adherence + +**Use ONLY the provided color palette. Do NOT create or modify colors.** + +- All colors must come from the user-provided palette +- Do NOT use colors outside the palette +- Do NOT modify palette colors (brightness, saturation, mixing) +- **Only exception**: Add transparency using the `transparency` property (0-100) + +```javascript +// Correct: Using palette colors +slide.addShape(pres.shapes.RECTANGLE, { fill: { color: theme.primary } }); +slide.addText("Title", { color: theme.accent }); + +// Wrong: Colors outside palette +slide.addShape(pres.shapes.RECTANGLE, { fill: { color: "1a1a2e" } }); +``` + +### No Gradients + +**Gradients are prohibited. Use solid colors only.** + +### No Animations + +**Animations and transitions are prohibited.** All slides must be static. + +--- + +## Font Reference + +### Recommended Fonts + +| Language | Default Font | Alternatives | +|----------|-------------|--------------| +| **Chinese** | Microsoft YaHei | — | +| **English** | Arial | Georgia, Calibri, Cambria, Trebuchet MS | + +- For mixed Chinese-English content: use Microsoft YaHei for Chinese, the chosen font for English +- Prefer system fonts for cross-platform compatibility +- Titles and body text can use different font pairings (e.g. Georgia + Calibri) + +### Recommended Font Pairings + +| Header Font | Body Font | +|-------------|-----------| +| Georgia | Calibri | +| Arial Black | Arial | +| Calibri | Calibri Light | +| Cambria | Calibri | +| Trebuchet MS | Calibri | +| Impact | Arial | +| Palatino | Garamond | +| Consolas | Calibri | + +**Choose an interesting font pairing** — don't default to Arial for everything. Pick a header font with personality and pair it with a clean body font. + +### No Bold for Body Text + +**Plain body text and caption/legend text must NOT use bold.** + +- Body paragraphs, descriptions → normal weight +- Captions, legends, footnotes → normal weight +- Reserve bold for titles and headings only + +```javascript +// Correct +slide.addText("Main Title", { bold: true, fontSize: 36, fontFace: "Arial" }); +slide.addText("Body text here.", { bold: false, fontSize: 14, fontFace: "Arial" }); + +// Wrong +slide.addText("Body text here.", { bold: true, fontSize: 14 }); +``` + +--- + +## Style Recipes + +The same design can be rendered in 4 distinct visual styles by adjusting corner radius (`rectRadius`) and spacing. Choose the style recipe that fits the presentation tone. + +> **Unit note**: PptxGenJS uses inches. Slide dimensions are 10" x 5.625" (LAYOUT_16x9). + +### Style Overview + +| Style | Corner Radius | Spacing | Best For | +|-------|--------------|---------|----------| +| **Sharp & Compact** | 0 ~ 0.05" | Tight | Data-dense, tables, professional reports | +| **Soft & Balanced** | 0.08" ~ 0.12" | Moderate | Corporate, business presentations, general use | +| **Rounded & Spacious** | 0.15" ~ 0.25" | Relaxed | Product intros, marketing, creative showcases | +| **Pill & Airy** | 0.3" ~ 0.5" | Open | Brand showcases, launch events, premium presentations | + +### Sharp & Compact + +**Visual character**: Geometric, high information density, formal and serious. + +| Category | Value (inches) | Notes | +|----------|---------------|-------| +| Corner radius — small | 0" | Full right angle | +| Corner radius — medium | 0.03" | Micro-rounded | +| Corner radius — large | 0.05" | Slight rounding | +| Element padding | 0.1" ~ 0.15" | Compact | +| Element gap | 0.1" ~ 0.2" | Compact | +| Page margin | 0.3" | Narrow | +| Block gap | 0.25" ~ 0.35" | Compact | + +### Soft & Balanced + +**Visual character**: Moderate rounding, comfortable whitespace, professional yet approachable. + +| Category | Value (inches) | Notes | +|----------|---------------|-------| +| Corner radius — small | 0.05" | Slight rounding | +| Corner radius — medium | 0.08" | Medium rounding | +| Corner radius — large | 0.12" | Larger rounding | +| Element padding | 0.15" ~ 0.2" | Moderate | +| Element gap | 0.15" ~ 0.25" | Moderate | +| Page margin | 0.4" | Standard | +| Block gap | 0.35" ~ 0.5" | Moderate | + +### Rounded & Spacious + +**Visual character**: Large corners, generous whitespace, friendly and modern. + +| Category | Value (inches) | Notes | +|----------|---------------|-------| +| Corner radius — small | 0.1" | Medium rounding | +| Corner radius — medium | 0.15" | Large rounding | +| Corner radius — large | 0.25" | Very large rounding | +| Element padding | 0.2" ~ 0.3" | Relaxed | +| Element gap | 0.25" ~ 0.4" | Relaxed | +| Page margin | 0.5" | Wide | +| Block gap | 0.5" ~ 0.7" | Relaxed | + +### Pill & Airy + +**Visual character**: Full pill-shaped corners, abundant whitespace, light and open feel, strong brand presence. + +| Category | Value (inches) | Notes | +|----------|---------------|-------| +| Corner radius — small | 0.2" | Large rounding | +| Corner radius — medium | 0.3" | Pill shape | +| Corner radius — large | 0.5" | Full pill | +| Element padding | 0.25" ~ 0.4" | Open | +| Element gap | 0.3" ~ 0.5" | Open | +| Page margin | 0.6" | Wide | +| Block gap | 0.6" ~ 0.9" | Open | + +### Component Style Mapping + +| Component | Sharp | Soft | Rounded | Pill | +|-----------|-------|------|---------|------| +| **Button / Tag** | rectRadius: 0 | rectRadius: 0.05 | rectRadius: 0.1 | rectRadius: 0.2 | +| **Card / Container** | rectRadius: 0.03 | rectRadius: 0.1 | rectRadius: 0.2 | rectRadius: 0.3 | +| **Image Container** | rectRadius: 0 | rectRadius: 0.08 | rectRadius: 0.15 | rectRadius: 0.25 | +| **Input Field** | rectRadius: 0 | rectRadius: 0.05 | rectRadius: 0.1 | rectRadius: 0.2 | +| **Badge** | rectRadius: 0.02 | rectRadius: 0.05 | rectRadius: 0.08 | rectRadius: 0.15 | +| **Avatar Frame** | rectRadius: 0 | rectRadius: 0.1 | rectRadius: 0.2 | rectRadius: 0.5 (circle) | + +#### PptxGenJS Corner Radius Examples + +```javascript +// Sharp style card +slide.addShape("rect", { + x: 0.5, y: 1, w: 4, h: 2.5, + fill: { color: "F5F5F5" }, + rectRadius: 0.03 +}); + +// Rounded style card +slide.addShape("rect", { + x: 0.5, y: 1, w: 4, h: 2.5, + fill: { color: "F5F5F5" }, + rectRadius: 0.2 +}); + +// Pill style button (height 0.4", rectRadius 0.2" = perfect pill) +slide.addShape("rect", { + x: 3, y: 4, w: 2, h: 0.4, + fill: { color: "4A90D9" }, + rectRadius: 0.2 +}); +``` + +### Mixing Rules + +#### 1. Outer container corner >= inner element corner + +```javascript +// Correct: outer > inner +card: rectRadius: 0.2 +button: rectRadius: 0.1 + +// Wrong: inner > outer → visual overflow effect +card: rectRadius: 0.1 +button: rectRadius: 0.2 +``` + +#### 2. Information density drives spacing + +| Zone Type | Recommended Style | +|-----------|------------------| +| Data display zone | Sharp / Soft (compact spacing) | +| Content browsing zone | Rounded / Pill (relaxed spacing) | +| Title zone | Soft / Rounded (moderate spacing) | + +#### 3. Corner radius vs element height + +| Element Height | Sharp | Soft | Rounded | Pill | +|---------------|-------|------|---------|------| +| Small (< 0.3") | 0" | 0.03" | 0.08" | height/2 | +| Medium (0.3" ~ 0.6") | 0.02" | 0.05" | 0.12" | height/2 | +| Large (0.6" ~ 1.2") | 0.03" | 0.08" | 0.2" | 0.3" | +| Extra large (> 1.2") | 0.05" | 0.12" | 0.25" | 0.4" | + +> **Pill tip**: For a perfect pill shape, set `rectRadius = element height / 2` + +### Typography Scale (PPT) + +| Usage | Size (pt) | Notes | +|-------|-----------|-------| +| Annotations / Sources | 10 ~ 12 | Minimum readable size | +| Body / Description | 14 ~ 16 | Standard body | +| Subtitle | 18 ~ 22 | Secondary heading | +| Title | 28 ~ 36 | Page title | +| Large Title | 44 ~ 60 | Cover / section title | +| Data Callout | 60 ~ 96 | Key number display | + +### Spacing Scale (PPT) + +Based on 10" x 5.625" slide dimensions: + +| Usage | Recommended (inches) | +|-------|---------------------| +| Icon-to-text gap | 0.08" ~ 0.15" | +| List item spacing | 0.15" ~ 0.25" | +| Card inner padding | 0.2" ~ 0.4" | +| Element group gap | 0.3" ~ 0.5" | +| Page safe margin | 0.4" ~ 0.6" | +| Major block gap | 0.5" ~ 0.8" | + +### Quick Selection Guide + +| Presentation Type | Recommended Style | Reason | +|------------------|------------------|--------| +| Finance / Data reports | Sharp & Compact | High density, serious and precise | +| Corporate / Business | Soft & Balanced | Balances professionalism and approachability | +| Product intro / Marketing | Rounded & Spacious | Modern feel, friendly | +| Launch events / Brand | Pill & Airy | Premium feel, visual impact | +| Training / Education | Soft / Rounded | Clear, readable, friendly | +| Tech sharing | Sharp / Soft | Professional, information-dense | diff --git a/backend/app/skills_builtin/pptx-generator/references/editing.md b/backend/app/skills_builtin/pptx-generator/references/editing.md new file mode 100644 index 0000000..ab2e9ca --- /dev/null +++ b/backend/app/skills_builtin/pptx-generator/references/editing.md @@ -0,0 +1,162 @@ +# Editing Existing Presentations + +## Template-Based Workflow + +When using an existing presentation as a template: + +1. **Copy and analyze**: + ```bash + cp /path/to/user-provided.pptx template.pptx + python -m markitdown template.pptx > template.md + ``` + Review `template.md` to see placeholder text and slide structure. + +2. **Plan slide mapping**: For each content section, choose a template slide. + + **USE VARIED LAYOUTS** — monotonous presentations are a common failure mode. Don't default to basic title + bullet slides. Actively seek out: + - Multi-column layouts (2-column, 3-column) + - Image + text combinations + - Full-bleed images with text overlay + - Quote or callout slides + - Section dividers + - Stat/number callouts + - Icon grids or icon + text rows + + **Avoid:** Repeating the same text-heavy layout for every slide. + + Match content type to layout style (e.g., key points -> bullet slide, team info -> multi-column, testimonials -> quote slide). + +3. **Unpack**: Extract the PPTX into an editable XML tree using Python's `zipfile` module. Pretty-print the XML for readability. + +4. **Build presentation** (do this yourself, not with subagents): + - Delete unwanted slides (remove from ``) + - Duplicate slides you want to reuse (copy slide XML, relationships, and update `Content_Types.xml` and `presentation.xml`) + - Reorder slides in `` + - **Complete all structural changes before step 5** + +5. **Edit content**: Update text in each `slide{N}.xml`. + **Use subagents here if available** — slides are separate XML files, so subagents can edit in parallel. + +6. **Clean**: Remove orphaned files — slides not in ``, unreferenced media, orphaned rels. + +7. **Pack**: Repack the XML tree into a PPTX file. Validate, repair, condense XML, re-encode smart quotes. + + Always write to `/tmp/` first, then copy to the final path. Python's `zipfile` module uses `seek` internally, which fails on some volume mounts (e.g. Docker bind mounts). Writing to a local temp path avoids this. + +## Output Structure + +Copy the user-provided file to `template.pptx` in cwd. This preserves the original and gives a predictable name for all downstream operations. + +```bash +cp /path/to/user-provided.pptx template.pptx +``` + +```text +./ +├── template.pptx # Copy of user-provided file (never modified) +├── template.md # markitdown extraction +├── unpacked/ # Editable XML tree +└── edited.pptx # Final repacked deck +``` + +Minimum expected deliverable: `edited.pptx`. + +## Slide Operations + +Slide order is in `ppt/presentation.xml` -> ``. + +**Reorder**: Rearrange `` elements. + +**Delete**: Remove ``, then clean orphaned files. + +**Add**: Copy the source slide's XML file, its `.rels` file, and update `Content_Types.xml` and `presentation.xml`. Never manually copy slide files without updating all references — this causes broken notes references and missing relationship IDs. + +## Editing Content + +**Subagents:** If available, use them here (after completing step 4). Each slide is a separate XML file, so subagents can edit in parallel. In your prompt to subagents, include: +- The slide file path(s) to edit +- **"Use the Edit tool for all changes"** +- The formatting rules and common pitfalls below + +For each slide: +1. Read the slide's XML +2. Identify ALL placeholder content — text, images, charts, icons, captions +3. Replace each placeholder with final content + +**Use the Edit tool, not sed or Python scripts.** The Edit tool forces specificity about what to replace and where, yielding better reliability. + +## Formatting Rules + +- **Bold all headers, subheadings, and inline labels**: Use `b="1"` on ``. This includes: + - Slide titles + - Section headers within a slide + - Inline labels like (e.g.: "Status:", "Description:") at the start of a line +- **Never use unicode bullets**: Use proper list formatting with `` or `` +- **Bullet consistency**: Let bullets inherit from the layout. Only specify `` or ``. + +## Common Pitfalls — Template Editing + +### Template Adaptation + +When source content has fewer items than the template: +- **Remove excess elements entirely** (images, shapes, text boxes), don't just clear text +- Check for orphaned visuals after clearing text content +- Run content QA with `markitdown` to catch mismatched counts + +When replacing text with different length content: +- **Shorter replacements**: Usually safe +- **Longer replacements**: May overflow or wrap unexpectedly +- Verify with `markitdown` after text changes +- Consider truncating or splitting content to fit the template's design constraints + +**Template slots != Source items**: If template has 4 team members but source has 3 users, delete the 4th member's entire group (image + text boxes), not just the text. + +### Multi-Item Content + +If source has multiple items (numbered lists, multiple sections), create separate `` elements for each — **never concatenate into one string**. + +**WRONG** — all items in one paragraph: +```xml + + Step 1: Do the first thing. Step 2: Do the second thing. + +``` + +**CORRECT** — separate paragraphs with bold headers: +```xml + + + Step 1 + + + + Do the first thing. + + + + Step 2 + + +``` + +Copy `` from the original paragraph to preserve line spacing. Use `b="1"` on headers. + +### Smart Quotes + +The Edit tool converts smart quotes to ASCII. **When adding new text with quotes, use XML entities:** + +```xml +the “Agreement” +``` + +| Character | Name | Unicode | XML Entity | +|-----------|------|---------|------------| +| \u201c | Left double quote | U+201C | `“` | +| \u201d | Right double quote | U+201D | `”` | +| \u2018 | Left single quote | U+2018 | `‘` | +| \u2019 | Right single quote | U+2019 | `’` | + +### Other + +- **Whitespace**: Use `xml:space="preserve"` on `` with leading/trailing spaces +- **XML parsing**: Use `defusedxml.minidom`, not `xml.etree.ElementTree` (corrupts namespaces) diff --git a/backend/app/skills_builtin/pptx-generator/references/pitfalls.md b/backend/app/skills_builtin/pptx-generator/references/pitfalls.md new file mode 100644 index 0000000..cb6eefc --- /dev/null +++ b/backend/app/skills_builtin/pptx-generator/references/pitfalls.md @@ -0,0 +1,112 @@ +# QA Process & Common Pitfalls + +## QA Process + +**Assume there are problems. Your job is to find them.** + +Your first render is almost never correct. Approach QA as a bug hunt, not a confirmation step. If you found zero issues on first inspection, you weren't looking hard enough. + +### Content QA + +```bash +python -m markitdown output.pptx +``` + +Check for missing content, typos, wrong order. + +**Check for leftover placeholder text:** + +```bash +python -m markitdown output.pptx | grep -iE "xxxx|lorem|ipsum|placeholder|this.*(page|slide).*layout" +``` + +If grep returns results, fix them before declaring success. + +### Verification Loop + +1. Generate slides -> Extract text with `python -m markitdown output.pptx` -> Review content +2. **List issues found** (if none found, look again more critically) +3. Fix issues +4. **Re-verify affected slides** — one fix often creates another problem +5. Repeat until a full pass reveals no new issues + +**Do not declare success until you've completed at least one fix-and-verify cycle.** + +### Per-Slide QA (for from-scratch creation) + +```bash +python -m markitdown slide-XX-preview.pptx +``` + +Check for missing content, placeholder text, missing page number badge. + +--- + +## Common Mistakes to Avoid + +- **Don't repeat the same layout** — vary columns, cards, and callouts across slides +- **Don't center body text** — left-align paragraphs and lists; center only titles +- **Don't skimp on size contrast** — titles need 36pt+ to stand out from 14-16pt body +- **Don't default to blue** — pick colors that reflect the specific topic +- **Don't mix spacing randomly** — choose 0.3" or 0.5" gaps and use consistently +- **Don't style one slide and leave the rest plain** — commit fully or keep it simple throughout +- **Don't create text-only slides** — add images, icons, charts, or visual elements; avoid plain title + bullets +- **Don't forget text box padding** — when aligning lines or shapes with text edges, set `margin: 0` on the text box or offset the shape to account for padding +- **Don't use low-contrast elements** — icons AND text need strong contrast against the background +- **NEVER use accent lines under titles** — these are a hallmark of AI-generated slides; use whitespace or background color instead +- **NEVER use "#" with hex colors** — causes file corruption in PptxGenJS +- **NEVER encode opacity in hex strings** — use the `opacity` property instead +- **NEVER use async/await in createSlide()** — compile.js won't await +- **NEVER reuse option objects across PptxGenJS calls** — PptxGenJS mutates objects in-place + +--- + +## Critical Pitfalls — PptxGenJS + +### NEVER use async/await in createSlide() + +```javascript +// WRONG - compile.js won't await +async function createSlide(pres, theme) { ... } + +// CORRECT +function createSlide(pres, theme) { ... } +``` + +### NEVER use "#" with hex colors + +```javascript +color: "FF0000" // CORRECT +color: "#FF0000" // CORRUPTS FILE +``` + +### NEVER encode opacity in hex strings + +```javascript +shadow: { color: "00000020" } // CORRUPTS FILE +shadow: { color: "000000", opacity: 0.12 } // CORRECT +``` + +### Prevent text wrapping in titles + +```javascript +// Use fit:'shrink' for long titles +slide.addText("Long Title Here", { + x: 0.5, y: 2, w: 9, h: 1, + fontSize: 48, fit: "shrink" +}); +``` + +### NEVER reuse option objects across calls + +```javascript +// WRONG +const shadow = { type: "outer", blur: 6, offset: 2, color: "000000", opacity: 0.15 }; +slide.addShape(pres.shapes.RECTANGLE, { shadow, ... }); +slide.addShape(pres.shapes.RECTANGLE, { shadow, ... }); + +// CORRECT - factory function +const makeShadow = () => ({ type: "outer", blur: 6, offset: 2, color: "000000", opacity: 0.15 }); +slide.addShape(pres.shapes.RECTANGLE, { shadow: makeShadow(), ... }); +slide.addShape(pres.shapes.RECTANGLE, { shadow: makeShadow(), ... }); +``` diff --git a/backend/app/skills_builtin/pptx-generator/references/pptxgenjs.md b/backend/app/skills_builtin/pptx-generator/references/pptxgenjs.md new file mode 100644 index 0000000..f649516 --- /dev/null +++ b/backend/app/skills_builtin/pptx-generator/references/pptxgenjs.md @@ -0,0 +1,420 @@ +# PptxGenJS Tutorial + +## Setup & Basic Structure + +```javascript +const pptxgen = require("pptxgenjs"); + +let pres = new pptxgen(); +pres.layout = 'LAYOUT_16x9'; // or 'LAYOUT_16x10', 'LAYOUT_4x3', 'LAYOUT_WIDE' +pres.author = 'Your Name'; +pres.title = 'Presentation Title'; + +let slide = pres.addSlide(); +slide.addText("Hello World!", { x: 0.5, y: 0.5, fontSize: 36, color: "363636" }); + +pres.writeFile({ fileName: "Presentation.pptx" }); +``` + +## Layout Dimensions + +Slide dimensions (coordinates in inches): +- `LAYOUT_16x9`: 10" x 5.625" (default) +- `LAYOUT_16x10`: 10" x 6.25" +- `LAYOUT_4x3`: 10" x 7.5" +- `LAYOUT_WIDE`: 13.3" x 7.5" + +--- + +## Text & Formatting + +```javascript +// Basic text +slide.addText("Simple Text", { + x: 1, y: 1, w: 8, h: 2, fontSize: 24, fontFace: "Arial", + color: "363636", bold: true, align: "center", valign: "middle" +}); + +// Character spacing (use charSpacing, not letterSpacing which is silently ignored) +slide.addText("SPACED TEXT", { x: 1, y: 1, w: 8, h: 1, charSpacing: 6 }); + +// Rich text arrays +slide.addText([ + { text: "Bold ", options: { bold: true } }, + { text: "Italic ", options: { italic: true } } +], { x: 1, y: 3, w: 8, h: 1 }); + +// Multi-line text (requires breakLine: true) +slide.addText([ + { text: "Line 1", options: { breakLine: true } }, + { text: "Line 2", options: { breakLine: true } }, + { text: "Line 3" } // Last item doesn't need breakLine +], { x: 0.5, y: 0.5, w: 8, h: 2 }); + +// Text box margin (internal padding) +slide.addText("Title", { + x: 0.5, y: 0.3, w: 9, h: 0.6, + margin: 0 // Use 0 when aligning text with other elements like shapes or icons +}); +``` + +**Tip:** Text boxes have internal margin by default. Set `margin: 0` when you need text to align precisely with shapes, lines, or icons at the same x-position. + +--- + +## Lists & Bullets + +```javascript +// CORRECT: Multiple bullets +slide.addText([ + { text: "First item", options: { bullet: true, breakLine: true } }, + { text: "Second item", options: { bullet: true, breakLine: true } }, + { text: "Third item", options: { bullet: true } } +], { x: 0.5, y: 0.5, w: 8, h: 3 }); + +// WRONG: Never use unicode bullets +slide.addText("* First item", { ... }); // Creates double bullets + +// Sub-items and numbered lists +{ text: "Sub-item", options: { bullet: true, indentLevel: 1 } } +{ text: "First", options: { bullet: { type: "number" }, breakLine: true } } +``` + +--- + +## Shapes + +```javascript +slide.addShape(pres.shapes.RECTANGLE, { + x: 0.5, y: 0.8, w: 1.5, h: 3.0, + fill: { color: "FF0000" }, line: { color: "000000", width: 2 } +}); + +slide.addShape(pres.shapes.OVAL, { x: 4, y: 1, w: 2, h: 2, fill: { color: "0000FF" } }); + +slide.addShape(pres.shapes.LINE, { + x: 1, y: 3, w: 5, h: 0, line: { color: "FF0000", width: 3, dashType: "dash" } +}); + +// With transparency +slide.addShape(pres.shapes.RECTANGLE, { + x: 1, y: 1, w: 3, h: 2, + fill: { color: "0088CC", transparency: 50 } +}); + +// Rounded rectangle (rectRadius only works with ROUNDED_RECTANGLE, not RECTANGLE) +// Don't pair with rectangular accent overlays -- they won't cover rounded corners. Use RECTANGLE instead. +slide.addShape(pres.shapes.ROUNDED_RECTANGLE, { + x: 1, y: 1, w: 3, h: 2, + fill: { color: "FFFFFF" }, rectRadius: 0.1 +}); + +// With shadow +slide.addShape(pres.shapes.RECTANGLE, { + x: 1, y: 1, w: 3, h: 2, + fill: { color: "FFFFFF" }, + shadow: { type: "outer", color: "000000", blur: 6, offset: 2, angle: 135, opacity: 0.15 } +}); +``` + +Shadow options: + +| Property | Type | Range | Notes | +|----------|------|-------|-------| +| `type` | string | `"outer"`, `"inner"` | | +| `color` | string | 6-char hex (e.g. `"000000"`) | No `#` prefix, no 8-char hex -- see Common Pitfalls | +| `blur` | number | 0-100 pt | | +| `offset` | number | 0-200 pt | **Must be non-negative** -- negative values corrupt the file | +| `angle` | number | 0-359 degrees | Direction the shadow falls (135 = bottom-right, 270 = upward) | +| `opacity` | number | 0.0-1.0 | Use this for transparency, never encode in color string | + +To cast a shadow upward (e.g. on a footer bar), use `angle: 270` with a positive offset -- do **not** use a negative offset. + +**Note**: Gradient fills are not natively supported. Use a gradient image as a background instead. + +--- + +## Images + +### Image Sources + +```javascript +// From file path +slide.addImage({ path: "images/chart.png", x: 1, y: 1, w: 5, h: 3 }); + +// From URL +slide.addImage({ path: "https://example.com/image.jpg", x: 1, y: 1, w: 5, h: 3 }); + +// From base64 (faster, no file I/O) +slide.addImage({ data: "image/png;base64,iVBORw0KGgo...", x: 1, y: 1, w: 5, h: 3 }); +``` + +### Image Options + +```javascript +slide.addImage({ + path: "image.png", + x: 1, y: 1, w: 5, h: 3, + rotate: 45, // 0-359 degrees + rounding: true, // Circular crop + transparency: 50, // 0-100 + flipH: true, // Horizontal flip + flipV: false, // Vertical flip + altText: "Description", // Accessibility + hyperlink: { url: "https://example.com" } +}); +``` + +### Image Sizing Modes + +```javascript +// Contain - fit inside, preserve ratio +{ sizing: { type: 'contain', w: 4, h: 3 } } + +// Cover - fill area, preserve ratio (may crop) +{ sizing: { type: 'cover', w: 4, h: 3 } } + +// Crop - cut specific portion +{ sizing: { type: 'crop', x: 0.5, y: 0.5, w: 2, h: 2 } } +``` + +### Calculate Dimensions (preserve aspect ratio) + +```javascript +const origWidth = 1978, origHeight = 923, maxHeight = 3.0; +const calcWidth = maxHeight * (origWidth / origHeight); +const centerX = (10 - calcWidth) / 2; + +slide.addImage({ path: "image.png", x: centerX, y: 1.2, w: calcWidth, h: maxHeight }); +``` + +### Supported Formats + +- **Standard**: PNG, JPG, GIF (animated GIFs work in Microsoft 365) +- **SVG**: Works in modern PowerPoint/Microsoft 365 + +--- + +## Icons + +Use react-icons to generate SVG icons, then rasterize to PNG for universal compatibility. + +### Setup + +```javascript +const React = require("react"); +const ReactDOMServer = require("react-dom/server"); +const sharp = require("sharp"); +const { FaCheckCircle, FaChartLine } = require("react-icons/fa"); + +function renderIconSvg(IconComponent, color = "#000000", size = 256) { + return ReactDOMServer.renderToStaticMarkup( + React.createElement(IconComponent, { color, size: String(size) }) + ); +} + +async function iconToBase64Png(IconComponent, color, size = 256) { + const svg = renderIconSvg(IconComponent, color, size); + const pngBuffer = await sharp(Buffer.from(svg)).png().toBuffer(); + return "image/png;base64," + pngBuffer.toString("base64"); +} +``` + +### Add Icon to Slide + +```javascript +const iconData = await iconToBase64Png(FaCheckCircle, "#4472C4", 256); + +slide.addImage({ + data: iconData, + x: 1, y: 1, w: 0.5, h: 0.5 // Size in inches +}); +``` + +**Note**: Use size 256 or higher for crisp icons. The size parameter controls the rasterization resolution, not the display size on the slide (which is set by `w` and `h` in inches). + +### Icon Libraries + +Install: `npm install -g react-icons react react-dom sharp` + +Popular icon sets in react-icons: +- `react-icons/fa` - Font Awesome +- `react-icons/md` - Material Design +- `react-icons/hi` - Heroicons +- `react-icons/bi` - Bootstrap Icons + +--- + +## Slide Backgrounds + +```javascript +// Solid color +slide.background = { color: "F1F1F1" }; + +// Color with transparency +slide.background = { color: "FF3399", transparency: 50 }; + +// Image from URL +slide.background = { path: "https://example.com/bg.jpg" }; + +// Image from base64 +slide.background = { data: "image/png;base64,iVBORw0KGgo..." }; +``` + +--- + +## Tables + +```javascript +slide.addTable([ + ["Header 1", "Header 2"], + ["Cell 1", "Cell 2"] +], { + x: 1, y: 1, w: 8, h: 2, + border: { pt: 1, color: "999999" }, fill: { color: "F1F1F1" } +}); + +// Advanced with merged cells +let tableData = [ + [{ text: "Header", options: { fill: { color: "6699CC" }, color: "FFFFFF", bold: true } }, "Cell"], + [{ text: "Merged", options: { colspan: 2 } }] +]; +slide.addTable(tableData, { x: 1, y: 3.5, w: 8, colW: [4, 4] }); +``` + +--- + +## Charts + +```javascript +// Bar chart +slide.addChart(pres.charts.BAR, [{ + name: "Sales", labels: ["Q1", "Q2", "Q3", "Q4"], values: [4500, 5500, 6200, 7100] +}], { + x: 0.5, y: 0.6, w: 6, h: 3, barDir: 'col', + showTitle: true, title: 'Quarterly Sales' +}); + +// Line chart +slide.addChart(pres.charts.LINE, [{ + name: "Temp", labels: ["Jan", "Feb", "Mar"], values: [32, 35, 42] +}], { x: 0.5, y: 4, w: 6, h: 3, lineSize: 3, lineSmooth: true }); + +// Pie chart +slide.addChart(pres.charts.PIE, [{ + name: "Share", labels: ["A", "B", "Other"], values: [35, 45, 20] +}], { x: 7, y: 1, w: 5, h: 4, showPercent: true }); +``` + +### Better-Looking Charts + +Default charts look dated. Apply these options for a modern, clean appearance: + +```javascript +slide.addChart(pres.charts.BAR, chartData, { + x: 0.5, y: 1, w: 9, h: 4, barDir: "col", + + // Custom colors (match your presentation palette) + chartColors: ["0D9488", "14B8A6", "5EEAD4"], + + // Clean background + chartArea: { fill: { color: "FFFFFF" }, roundedCorners: true }, + + // Muted axis labels + catAxisLabelColor: "64748B", + valAxisLabelColor: "64748B", + + // Subtle grid (value axis only) + valGridLine: { color: "E2E8F0", size: 0.5 }, + catGridLine: { style: "none" }, + + // Data labels on bars + showValue: true, + dataLabelPosition: "outEnd", + dataLabelColor: "1E293B", + + // Hide legend for single series + showLegend: false, +}); +``` + +**Key styling options:** +- `chartColors: [...]` - hex colors for series/segments +- `chartArea: { fill, border, roundedCorners }` - chart background +- `catGridLine/valGridLine: { color, style, size }` - grid lines (`style: "none"` to hide) +- `lineSmooth: true` - curved lines (line charts) +- `legendPos: "r"` - legend position: "b", "t", "l", "r", "tr" + +--- + +## Slide Masters + +```javascript +pres.defineSlideMaster({ + title: 'TITLE_SLIDE', background: { color: '283A5E' }, + objects: [{ + placeholder: { options: { name: 'title', type: 'title', x: 1, y: 2, w: 8, h: 2 } } + }] +}); + +let titleSlide = pres.addSlide({ masterName: "TITLE_SLIDE" }); +titleSlide.addText("My Title", { placeholder: "title" }); +``` + +--- + +## Common Pitfalls + +These issues cause file corruption, visual bugs, or broken output. Avoid them. + +1. **NEVER use "#" with hex colors** - causes file corruption + ```javascript + color: "FF0000" // CORRECT + color: "#FF0000" // WRONG + ``` + +2. **NEVER encode opacity in hex color strings** - 8-char colors (e.g., `"00000020"`) corrupt the file. Use the `opacity` property instead. + ```javascript + shadow: { type: "outer", blur: 6, offset: 2, color: "00000020" } // CORRUPTS FILE + shadow: { type: "outer", blur: 6, offset: 2, color: "000000", opacity: 0.12 } // CORRECT + ``` + +3. **Use `bullet: true`** - NEVER unicode symbols like "o" (creates double bullets) + +4. **Use `breakLine: true`** between array items or text runs together + +5. **Avoid `lineSpacing` with bullets** - causes excessive gaps; use `paraSpaceAfter` instead + +6. **Each presentation needs fresh instance** - don't reuse `pptxgen()` objects + +7. **NEVER reuse option objects across calls** - PptxGenJS mutates objects in-place (e.g. converting shadow values to EMU). Sharing one object between multiple calls corrupts the second shape. + ```javascript + const shadow = { type: "outer", blur: 6, offset: 2, color: "000000", opacity: 0.15 }; + slide.addShape(pres.shapes.RECTANGLE, { shadow, ... }); // second call gets already-converted values + slide.addShape(pres.shapes.RECTANGLE, { shadow, ... }); + + const makeShadow = () => ({ type: "outer", blur: 6, offset: 2, color: "000000", opacity: 0.15 }); + slide.addShape(pres.shapes.RECTANGLE, { shadow: makeShadow(), ... }); // fresh object each time + slide.addShape(pres.shapes.RECTANGLE, { shadow: makeShadow(), ... }); + ``` + +8. **Don't use `ROUNDED_RECTANGLE` with accent borders** - rectangular overlay bars won't cover rounded corners. Use `RECTANGLE` instead. + ```javascript + // WRONG: Accent bar doesn't cover rounded corners + slide.addShape(pres.shapes.ROUNDED_RECTANGLE, { x: 1, y: 1, w: 3, h: 1.5, fill: { color: "FFFFFF" } }); + slide.addShape(pres.shapes.RECTANGLE, { x: 1, y: 1, w: 0.08, h: 1.5, fill: { color: "0891B2" } }); + + // CORRECT: Use RECTANGLE for clean alignment + slide.addShape(pres.shapes.RECTANGLE, { x: 1, y: 1, w: 3, h: 1.5, fill: { color: "FFFFFF" } }); + slide.addShape(pres.shapes.RECTANGLE, { x: 1, y: 1, w: 0.08, h: 1.5, fill: { color: "0891B2" } }); + ``` + +--- + +## Quick Reference + +- **Shapes**: RECTANGLE, OVAL, LINE, ROUNDED_RECTANGLE +- **Charts**: BAR, LINE, PIE, DOUGHNUT, SCATTER, BUBBLE, RADAR +- **Layouts**: LAYOUT_16x9 (10"x5.625"), LAYOUT_16x10, LAYOUT_4x3, LAYOUT_WIDE +- **Alignment**: "left", "center", "right" +- **Chart data labels**: "outEnd", "inEnd", "center" diff --git a/backend/app/skills_builtin/pptx-generator/references/slide-types.md b/backend/app/skills_builtin/pptx-generator/references/slide-types.md new file mode 100644 index 0000000..699560c --- /dev/null +++ b/backend/app/skills_builtin/pptx-generator/references/slide-types.md @@ -0,0 +1,413 @@ +# Slide Page Types + +Classify **every slide** as **exactly one** of these 5 types: + +## 1. Cover Page + +- **Use for**: Opening + tone setting +- **Content**: Big title, subtitle/presenter, date/occasion, strong background/motif + +### Layout Options + +**Asymmetric Left-Right Layout** +- Text concentrated on one side, image on the opposite +- Best for: Corporate presentations, product launches, professional reports +``` +| Title & Subtitle | Visual/Image | +| Description | | +``` + +**Center-Aligned Layout** +- Content centered with background image +- Best for: Inspirational talks, event presentations, creative pitches +``` +| | +| [Background Image] | +| MAIN TITLE | +| Subtitle | +| | +``` + +### Font Size Hierarchy + +| Element | Recommended Size | Ratio to Base | +|---------|-----------------|---------------| +| Main Title | 72-120px | 3x-5x | +| Subtitle | 28-40px | 1.5x-2x | +| Supporting Text | 18-24px | 1x (base) | +| Meta Info (date, name) | 14-18px | 0.7x-1x | + +**Key Principles:** +1. **Dramatic Contrast**: Main title should be at least 2-3x larger than subtitle +2. **Visual Anchor**: The largest text becomes the focal point +3. **Readable Hierarchy**: Viewers should instantly understand what's most important +4. **Avoid Similarity**: Never let adjacent text elements be within 20% of each other's size + +### Content Elements + +1. **Main Title** — Always required, largest font +2. **Subtitle** — When additional context is needed (clearly smaller than title) +3. **Icons** — When they reinforce the theme +4. **Date/Event Info** — When relevant (smallest text) +5. **Company/Brand Logo** — When representing an organization +6. **Presenter Name** — For keynotes (small, subtle) + +### Design Decisions + +Consider: Purpose (corporate/educational/creative), Audience, Tone, Content Volume, Visual Assets needed. + +### Workflow + +1. **Analyze**: Understand topic, audience, purpose +2. **Choose Layout**: Select based on content +3. **Write Slide**: Use PptxGenJS. Use shapes and SVG elements for visual interest. +4. **Verify**: Generate preview as `slide-XX-preview.pptx`. Extract text with `python -m markitdown slide-XX-preview.pptx`, verify all content present and no placeholder text remains. + +--- + +## 2. Table of Contents + +- **Use for**: Navigation + expectation setting (3-5 sections) +- **Content**: Section list (optional icons / page numbers) + +### Layout Options + +**Numbered Vertical List** — Best for 3-5 sections, straightforward presentations +``` +| TABLE OF CONTENTS | +| | +| 01 Section Title One | +| 02 Section Title Two | +| 03 Section Title Three | +``` + +**Two-Column Grid** — Best for 4-6 sections, content-rich presentations +``` +| TABLE OF CONTENTS | +| | +| 01 Section One 02 Section Two | +| Description Description | +| 03 Section Three 04 Section Four | +``` + +**Sidebar Navigation** — Best for 3-5 sections, modern/corporate +``` +| ▌01 | Section Title One | +| ▌02 | Section Title Two | +| ▌03 | Section Title Three | +``` + +**Card-Based** — Best for 3-4 sections, creative/modern +``` +| TABLE OF CONTENTS | +| ┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐ | +| │ 01 │ │ 02 │ │ 03 │ │ 04 │ | +| │Title│ │Title│ │Title│ │Title│ | +| └─────┘ └─────┘ └─────┘ └─────┘ | +``` + +### Font Size Hierarchy + +| Element | Recommended Size | Ratio to Base | +|---------|-----------------|---------------| +| Page Title ("Table of Contents" / "Agenda") | 36-44px | 2.5x-3x | +| Section Number | 28-36px | 2x-2.5x | +| Section Title | 20-28px | 1.5x-2x | +| Section Description | 14-16px | 1x (base) | + +**Key Principles:** +1. **Clear Numbering**: Section numbers should be visually prominent — bold, accent color, or larger size +2. **Scannable Structure**: Viewer should scan all sections in 2-3 seconds +3. **Consistent Spacing**: Equal vertical spacing between sections +4. **Visual Markers**: Colored dots, lines, numbers, or icons to anchor each section +5. **Avoid Clutter**: Descriptions one line max or omit entirely + +### Content Elements + +1. **Page Title** — Always required ("Table of Contents", "Agenda", "Overview") +2. **Section Numbers** — Consistent format (01, 02... or I, II...) +3. **Section Titles** — Clear and concise +4. **Section Descriptions** — Optional one-line summaries +5. **Visual Separators** — SVG dividers or spacing +6. **Decorative Elements** — Subtle accent shapes +7. **Page Number Badge** — **MANDATORY** + +### Design Decisions + +1. **Section Count**: 3 → vertical list; 4-6 → grid or compact; 7+ → multi-column +2. **Description Length**: Long → vertical list; None → compact grid/cards +3. **Tone**: Corporate → numbered list; Creative → card-based; Academic → Roman numerals +4. **Consistency**: Match visual style of cover page + +### Workflow + +1. **Analyze**: Section list, count, presentation context +2. **Choose Layout**: Based on section count and content +3. **Plan Visual Hierarchy**: Numbering style, font sizes, spacing +4. **Write Slide**: Use PptxGenJS. Use shapes for decorative elements. **MUST include page number badge.** +5. **Verify**: Generate preview, extract text with markitdown, verify content and badge. + +--- + +## 3. Section Divider + +- **Use for**: Clear transitions between major parts +- **Content**: Section number + title (+ optional 1-2 line intro) + +### Layout Options + +**Bold Center** — Best for minimal, modern presentations +``` +| 02 | +| SECTION TITLE | +| Optional intro line | +``` + +**Left-Aligned with Accent Block** — Best for corporate, structured presentations +``` +| ████ | 02 | +| ████ | SECTION TITLE | +| ████ | Optional intro line | +``` + +**Split Background** — Best for high-contrast, dramatic transitions +``` +| ██████████ | SECTION TITLE | +| ██ 02 ██ | Optional intro | +| ██████████ | | +``` + +**Full-Bleed Background with Overlay** — Best for creative, bold presentations +``` +| ████████████████████████████████████ | +| ████ large 02 █████████ | +| ████ SECTION TITLE █████████ | +| ████████████████████████████████████ | +``` + +### Font Size Hierarchy + +| Element | Recommended Size | Notes | +|---------|-----------------|-------| +| Section Number | 72-120px | Bold, accent color or semi-transparent | +| Section Title | 36-48px | Bold, clear, primary text color | +| Intro Text | 16-20px | Light weight, muted color, optional | + +**Key Principles:** +1. **Dramatic Number**: Section number = most prominent visual element +2. **Strong Title**: Large but clearly secondary to the number +3. **Minimal Content**: Just number + title + optional one-liner +4. **Breathing Room**: Leave generous whitespace — dividers are pause moments + +### Content Elements + +1. **Section Number** — Always required. Format: `01`, `02`... or `I`, `II`... Match TOC style. +2. **Section Title** — Always required. Clear, concise. +3. **Intro Text** — Optional 1-2 line description. +4. **Decorative Elements** — SVG accent shapes (bars, lines, geometric blocks). +5. **Page Number Badge** — **MANDATORY**. + +### Design Decisions + +1. **Tone**: Corporate → accent block; Creative → full-bleed; Minimal → bold center +2. **Color**: Strong palette color for background/accent; high-contrast text +3. **Consistency**: Same divider style across all dividers in one presentation +4. **Contrast with content slides**: Visually distinct (different background color, more whitespace) + +### Workflow + +1. **Analyze**: Section number, title, optional intro +2. **Choose Layout**: Based on content and tone +3. **Write Slide**: Use PptxGenJS. Use shapes for decorative elements. **MUST include page number badge.** +4. **Verify**: Generate preview, extract text, verify content and badge. + +--- + +## 4. Content Page + +Pick a subtype based on the content. Each content slide belongs to exactly ONE subtype: + +### Subtypes + +**Text** — Bullets, quotes, or short paragraphs +- Must still include icons or SVG shapes — never plain text only +``` +| SLIDE TITLE | +| * Bullet point one | +| * Bullet point two | +| * Bullet point three | +``` + +**Mixed Media** — Two-column or half-bleed image + text +``` +| SLIDE TITLE | +| Text content | [Image/Visual] | +| and bullets | | +``` + +**Data Visualization** — Chart (SVG bar/progress/ring) + takeaways +- Must include data source +``` +| SLIDE TITLE | +| [SVG Chart] | Key Takeaway 1 | +| | Key Takeaway 2 | +| Source: xxx | +``` + +**Comparison** — Side-by-side columns or cards (A vs B, pros/cons) +``` +| SLIDE TITLE | +| ┌─ Option A ─┐ ┌─ Option B ─┐ | +| │ Detail 1 │ │ Detail 1 │ | +| └────────────┘ └────────────┘ | +``` + +**Timeline / Process** — Steps with arrows, journey, phases +``` +| SLIDE TITLE | +| [1] ──→ [2] ──→ [3] ──→ [4] | +| Step Step Step Step | +``` + +**Image Showcase** — Hero image, gallery, visual-first layout +``` +| SLIDE TITLE | +| ┌────────────────────────────────┐ | +| │ [Hero Image] │ | +| └────────────────────────────────┘ | +| Caption or supporting text | +``` + +### Font Size Hierarchy + +| Element | Recommended Size | Notes | +|---------|-----------------|-------| +| Slide Title | 36-44px | Bold, top of slide | +| Section Header | 20-24px | Bold, for sub-sections within slide | +| Body Text | 14-16px | Regular weight, left-aligned | +| Captions / Source | 10-12px | Muted color, smallest text | +| Stat Callout | 60-72px | Large bold numbers for key statistics | + +**Key Principles:** +1. **Left-align body text** — never center paragraphs or bullet lists +2. **Size contrast** — title must be 36pt+ to stand out from 14-16pt body +3. **Visual elements required** — every content slide must have at least one non-text element +4. **Breathing room** — 0.5" minimum margins, 0.3-0.5" between content blocks + +### Content Elements + +1. **Slide Title** — Always required, top of slide +2. **Body Content** — Text, bullets, data, or comparisons based on subtype +3. **Visual Element** — Image, chart, icon, or SVG shape — always required +4. **Source / Caption** — When showing data or external content +5. **Page Number Badge** — **MANDATORY** + +### Design Decisions + +1. **Subtype**: Determine first — drives the entire layout +2. **Content Volume**: Dense → multi-column or smaller font; Light → larger elements with more whitespace +3. **Data vs Narrative**: Data-heavy → charts + stat callouts; Story-driven → images + quotes +4. **Variety**: Each content slide should use a different layout from the previous one +5. **Consistency**: Typography, colors, and spacing must match the rest of the presentation + +### Workflow + +1. **Analyze**: Content, determine subtype, plan layout +2. **Choose Layout**: Best fit for subtype and content volume +3. **Write Slide**: Use PptxGenJS. Use shapes for charts, decorative elements, icons. **MUST include page number badge.** +4. **Verify**: Generate preview as `slide-XX-preview.pptx`. Extract text with markitdown, verify all content present, no placeholder text, badge included. + +--- + +## 5. Summary / Closing Page + +- **Use for**: Wrap-up + action +- **Content**: Key takeaways, CTA/next steps, contact/QR, thank-you + +### Layout Options + +**Key Takeaways** — Best for educational, corporate, data-driven presentations +``` +| KEY TAKEAWAYS | +| ✓ Takeaway one | +| ✓ Takeaway two | +| ✓ Takeaway three | +``` + +**CTA / Next Steps** — Best for sales pitches, proposals, project kick-offs +``` +| NEXT STEPS | +| [1] Action item one | +| [2] Action item two | +| Contact: email@example.com | +``` + +**Thank You / Contact** — Best for conference talks, keynotes +``` +| THANK YOU | +| name@company.com | +| @handle | website.com | +``` + +**Split Recap** — Best for presentations needing both recap and action +``` +| SUMMARY | NEXT STEPS | +| * Point one | Contact us at | +| * Point two | email@co.com | +| * Point three | [QR Code] | +``` + +### Font Size Hierarchy + +| Element | Recommended Size | Notes | +|---------|-----------------|-------| +| Closing Title ("Thank You" / "Summary") | 48-72px | Bold, commanding | +| Takeaway / Action Item | 18-24px | Clear, scannable | +| Supporting Text | 14-16px | Regular weight | +| Contact Info | 14-16px | Muted color | + +**Key Principles:** +1. **Strong closing statement**: Main message should be largest, most prominent +2. **Scannable items**: Takeaways/action items concise (one line each) +3. **Contact clarity**: Legible but not dominant +4. **Memorable finish**: Confident, polished ending + +### Content Elements + +1. **Closing Title** — Always required +2. **Takeaway Points** — 3-5 concise summary points (if applicable) +3. **Call to Action** — Clear next steps (if applicable) +4. **Contact Info** — Email, website, social handles (if provided) +5. **Decorative Elements** — SVG accents for visual consistency +6. **Page Number Badge** — **MANDATORY** + +### Design Decisions + +1. **Closing Type**: Recap, CTA, thank-you, or combination +2. **Content Volume**: Many takeaways → list; Simple closing → centered thank-you +3. **Audience Action**: Audience needs to do something → CTA; Informational → takeaways +4. **Tone Consistency**: Match energy of cover page +5. **Visual Distinction**: Special but not disconnected from the rest + +### Workflow + +1. **Analyze**: Closing content — takeaways, CTA, contact, thank-you +2. **Choose Layout**: Based on content type +3. **Write Slide**: Use PptxGenJS. Use shapes for decorative elements. **MUST include page number badge.** +4. **Verify**: Generate preview, extract text, verify content and badge. + +--- + +## Additional Layout Patterns + +Use these across content slides for visual variety: + +- **Two-column** (text left, illustration right) +- **Icon + text rows** (icon in colored circle, bold header, description below) +- **2x2 or 2x3 grid** (image on one side, grid of content blocks on other) +- **Half-bleed image** (full left or right side) with content overlay +- **Large stat callouts** (big numbers 60-72pt with small labels below) +- **Comparison columns** (before/after, pros/cons) +- **Timeline or process flow** (numbered steps, arrows) +- **Icons in small colored circles** next to section headers +- **Italic accent text** for key stats or taglines