dsh-office-kit 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CAPABILITY.md +147 -68
- package/README.md +3 -1
- package/assets/skills/excel-live-control/SKILL.md +234 -0
- package/assets/skills/excel-live-control/container_tools/live_excel.py +1589 -0
- package/assets/skills/excel-live-control/domain_guidance/financial_models.md +86 -0
- package/assets/skills/excel-live-control/domain_guidance/healthcare.md +58 -0
- package/assets/skills/excel-live-control/domain_guidance/marketing_advertising.md +53 -0
- package/assets/skills/excel-live-control/domain_guidance/scientific_research.md +53 -0
- package/assets/skills/excel-live-control/references/office-automation.md +207 -0
- package/assets/skills/excel-live-control/references/read_only_qna.md +59 -0
- package/assets/skills/excel-live-control/style_guidelines.md +87 -0
- package/assets/skills/excel-live-control/workflows/create_workflows.md +78 -0
- package/assets/skills/excel-live-control/workflows/edit_workflows.md +72 -0
- package/assets/skills/office-documents/SKILL.md +90 -77
- package/assets/skills/office-documents/examples/end_to_end_smoke_test.md +86 -0
- package/assets/skills/office-documents/ooxml/comments.md +40 -0
- package/assets/skills/office-documents/ooxml/hyperlinks_and_fields.md +66 -0
- package/assets/skills/office-documents/ooxml/rels_and_content_types.md +62 -0
- package/assets/skills/office-documents/ooxml/tracked_changes.md +53 -0
- package/assets/skills/office-documents/references/docx-structure.md +83 -0
- package/assets/skills/office-documents/references/template-elicitation.md +49 -0
- package/assets/skills/office-documents/references/writing-quality.md +49 -0
- package/assets/skills/office-documents/render_docx.py +92 -0
- package/assets/skills/office-documents/scripts/a11y_audit.py +194 -0
- package/assets/skills/office-documents/scripts/accept_tracked_changes.py +151 -0
- package/assets/skills/office-documents/scripts/add_tracked_replacements.py +148 -0
- package/assets/skills/office-documents/scripts/append_blocks.py +108 -0
- package/assets/skills/office-documents/scripts/apply_template_styles.py +104 -0
- package/assets/skills/office-documents/scripts/captions_and_crossrefs.py +102 -0
- package/assets/skills/office-documents/scripts/comments_add.py +136 -0
- package/assets/skills/office-documents/scripts/comments_apply_patch.py +114 -0
- package/assets/skills/office-documents/scripts/comments_extract.py +85 -0
- package/assets/skills/office-documents/scripts/comments_strip.py +74 -0
- package/assets/skills/office-documents/scripts/content_controls.py +192 -0
- package/assets/skills/office-documents/scripts/core_properties.py +124 -0
- package/assets/skills/office-documents/scripts/docx_kit.py +587 -0
- package/assets/skills/office-documents/scripts/docx_ooxml_patch.py +225 -0
- package/assets/skills/office-documents/scripts/docx_table_to_csv.py +99 -0
- package/assets/skills/office-documents/scripts/fields_materialize.py +66 -0
- package/assets/skills/office-documents/scripts/fields_report.py +59 -0
- package/assets/skills/office-documents/scripts/flatten_ref_fields.py +62 -0
- package/assets/skills/office-documents/scripts/footnotes_report.py +77 -0
- package/assets/skills/office-documents/scripts/google_docs_title_sanitize.py +96 -0
- package/assets/skills/office-documents/scripts/heading_audit.py +81 -0
- package/assets/skills/office-documents/scripts/images_audit.py +80 -0
- package/assets/skills/office-documents/scripts/insert_note.py +126 -0
- package/assets/skills/office-documents/scripts/insert_ref_fields.py +90 -0
- package/assets/skills/office-documents/scripts/insert_toc.py +84 -0
- package/assets/skills/office-documents/scripts/internal_nav.py +146 -0
- package/assets/skills/office-documents/scripts/make_fixtures.py +169 -0
- package/assets/skills/office-documents/scripts/merge_docx_append.py +239 -0
- package/assets/skills/office-documents/scripts/privacy_scrub.py +131 -0
- package/assets/skills/office-documents/scripts/redact_docx.py +113 -0
- package/assets/skills/office-documents/scripts/render_and_diff.py +101 -0
- package/assets/skills/office-documents/scripts/replace_text.py +154 -0
- package/assets/skills/office-documents/scripts/section_audit.py +91 -0
- package/assets/skills/office-documents/scripts/set_protection.py +92 -0
- package/assets/skills/office-documents/scripts/style_lint.py +111 -0
- package/assets/skills/office-documents/scripts/style_normalize.py +103 -0
- package/assets/skills/office-documents/scripts/table_geometry.py +167 -0
- package/assets/skills/office-documents/scripts/watermark_add.py +48 -0
- package/assets/skills/office-documents/scripts/watermark_audit_remove.py +67 -0
- package/assets/skills/office-documents/scripts/xlsx_to_docx_table.py +117 -0
- package/assets/skills/office-documents/tasks/accessibility_a11y.md +45 -0
- package/assets/skills/office-documents/tasks/captions_crossrefs.md +49 -0
- package/assets/skills/office-documents/tasks/clean_tracked_changes.md +49 -0
- package/assets/skills/office-documents/tasks/comments_manage.md +59 -0
- package/assets/skills/office-documents/tasks/compare_diff.md +40 -0
- package/assets/skills/office-documents/tasks/create_edit.md +107 -0
- package/assets/skills/office-documents/tasks/equations.md +59 -0
- package/assets/skills/office-documents/tasks/fields_update.md +44 -0
- package/assets/skills/office-documents/tasks/fixtures_edge_cases.md +50 -0
- package/assets/skills/office-documents/tasks/footnotes_endnotes.md +38 -0
- package/assets/skills/office-documents/tasks/forms_content_controls.md +48 -0
- package/assets/skills/office-documents/tasks/headings_numbering.md +55 -0
- package/assets/skills/office-documents/tasks/images_figures.md +63 -0
- package/assets/skills/office-documents/tasks/multi_doc_merge.md +43 -0
- package/assets/skills/office-documents/tasks/navigation_internal_links.md +38 -0
- package/assets/skills/office-documents/tasks/privacy_scrub_metadata.md +45 -0
- package/assets/skills/office-documents/tasks/protection_restrict_editing.md +41 -0
- package/assets/skills/office-documents/tasks/read_review.md +50 -0
- package/assets/skills/office-documents/tasks/redaction_anonymization.md +42 -0
- package/assets/skills/office-documents/tasks/sections_layout.md +54 -0
- package/assets/skills/office-documents/tasks/style_lint_normalize.md +44 -0
- package/assets/skills/office-documents/tasks/tables_spreadsheets.md +64 -0
- package/assets/skills/office-documents/tasks/templates_style_packs.md +53 -0
- package/assets/skills/office-documents/tasks/toc_workflow.md +39 -0
- package/assets/skills/office-documents/tasks/verify_render.md +50 -0
- package/assets/skills/office-documents/tasks/watermarks_background.md +35 -0
- package/assets/skills/office-documents/troubleshooting/libreoffice_headless.md +47 -0
- package/assets/skills/office-documents/troubleshooting/run_splitting.md +36 -0
- package/assets/skills/office-pdf/SKILL.md +73 -56
- package/assets/skills/office-pdf/references/form-fields.md +56 -0
- package/assets/skills/office-pdf/references/geometry.md +44 -0
- package/assets/skills/office-pdf/references/layout-quality.md +41 -0
- package/assets/skills/office-pdf/references/limits.md +58 -0
- package/assets/skills/office-pdf/scripts/extract_text.py +172 -0
- package/assets/skills/office-pdf/scripts/forms.py +556 -0
- package/assets/skills/office-pdf/scripts/metadata.py +161 -0
- package/assets/skills/office-pdf/scripts/page_ops.py +198 -0
- package/assets/skills/office-pdf/scripts/redact_region.py +347 -0
- package/assets/skills/office-pdf/scripts/render_pages.py +207 -0
- package/assets/skills/office-pdf/scripts/split_pages.py +155 -0
- package/assets/skills/office-pdf/scripts/stamp_overlay.py +199 -0
- package/assets/skills/office-pdf/tasks/annotations.md +98 -0
- package/assets/skills/office-pdf/tasks/create.md +75 -0
- package/assets/skills/office-pdf/tasks/edit-pages.md +71 -0
- package/assets/skills/office-pdf/tasks/forms.md +67 -0
- package/assets/skills/office-pdf/tasks/read.md +62 -0
- package/assets/skills/office-pdf/tasks/redact.md +72 -0
- package/assets/skills/office-pdf/tasks/stamp.md +60 -0
- package/assets/skills/office-presentations/SKILL.md +141 -87
- package/assets/skills/office-presentations/container_tools/add_slides.py +136 -0
- package/assets/skills/office-presentations/container_tools/core_properties.py +120 -0
- package/assets/skills/office-presentations/container_tools/create_montage.py +126 -0
- package/assets/skills/office-presentations/container_tools/inspect_presentation_layout_geometry.py +285 -0
- package/assets/skills/office-presentations/container_tools/inspect_presentation_package_integrity.py +157 -0
- package/assets/skills/office-presentations/container_tools/native_chart_title_gate.py +144 -0
- package/assets/skills/office-presentations/container_tools/native_quantitative_chart_gate.py +199 -0
- package/assets/skills/office-presentations/container_tools/native_table_arithmetic_gate.py +212 -0
- package/assets/skills/office-presentations/container_tools/ooxml_helpers.py +345 -0
- package/assets/skills/office-presentations/container_tools/preview_render.py +375 -0
- package/assets/skills/office-presentations/container_tools/render_slides.py +139 -0
- package/assets/skills/office-presentations/container_tools/replace_text.py +168 -0
- package/assets/skills/office-presentations/container_tools/template_source_fidelity_gate.py +229 -0
- package/assets/skills/office-presentations/domain_guidance/finance.md +66 -0
- package/assets/skills/office-presentations/recipes/README.md +71 -0
- package/assets/skills/office-presentations/recipes/autolayout.md +88 -0
- package/assets/skills/office-presentations/recipes/connectors.md +83 -0
- package/assets/skills/office-presentations/recipes/images.md +80 -0
- package/assets/skills/office-presentations/recipes/master-and-layouts.md +127 -0
- package/assets/skills/office-presentations/recipes/native-charts.md +87 -0
- package/assets/skills/office-presentations/recipes/rich-text-runs.md +86 -0
- package/assets/skills/office-presentations/recipes/speaker-notes.md +65 -0
- package/assets/skills/office-presentations/recipes/tables.md +88 -0
- package/assets/skills/office-presentations/recipes/textbox-at-coordinate.md +71 -0
- package/assets/skills/office-presentations/recipes/theme-tokens.md +87 -0
- package/assets/skills/office-presentations/references/authoring-workflow.md +65 -0
- package/assets/skills/office-presentations/references/finalization.md +63 -0
- package/assets/skills/office-presentations/references/native-bullets.md +78 -0
- package/assets/skills/office-presentations/references/native-evidence.md +67 -0
- package/assets/skills/office-presentations/references/style-guidelines.md +66 -0
- package/assets/skills/office-presentations/references/templates.md +76 -0
- package/assets/skills/office-presentations/routing/google_slides.md +45 -0
- package/assets/skills/office-presentations/tasks/apply-annotations.md +83 -0
- package/assets/skills/office-presentations/tasks/charts-tables-notes.md +79 -0
- package/assets/skills/office-presentations/tasks/create-deck.md +106 -0
- package/assets/skills/office-presentations/tasks/edit-deck.md +93 -0
- package/assets/skills/office-presentations/tasks/inspect-and-verify.md +107 -0
- package/assets/skills/office-presentations/tasks/template-following.md +132 -0
- package/assets/skills/office-presentations/template_following_scripts/check_template_fidelity.py +176 -0
- package/assets/skills/office-presentations/template_following_scripts/inspect_template_deck.py +154 -0
- package/assets/skills/office-presentations/template_following_scripts/make_contact_sheet.py +111 -0
- package/assets/skills/office-presentations/template_following_scripts/prepare_template_starter_deck.py +239 -0
- package/assets/skills/office-presentations/template_following_scripts/validate_template_plan.py +185 -0
- package/assets/skills/office-spreadsheets/SKILL.md +105 -101
- package/assets/skills/office-spreadsheets/container_tools/add_chart.py +236 -0
- package/assets/skills/office-spreadsheets/container_tools/add_image.py +107 -0
- package/assets/skills/office-spreadsheets/container_tools/append_rows.py +122 -0
- package/assets/skills/office-spreadsheets/container_tools/diff_workbooks.py +306 -0
- package/assets/skills/office-spreadsheets/container_tools/inspect_workbook.py +398 -0
- package/assets/skills/office-spreadsheets/container_tools/recalc_gate.py +275 -0
- package/assets/skills/office-spreadsheets/container_tools/set_cells.py +135 -0
- package/assets/skills/office-spreadsheets/container_tools/sheet_ops.py +134 -0
- package/assets/skills/office-spreadsheets/container_tools/style_cells.py +133 -0
- package/assets/skills/office-spreadsheets/recipes/charts.md +66 -0
- package/assets/skills/office-spreadsheets/recipes/conditional-formats.md +69 -0
- package/assets/skills/office-spreadsheets/recipes/data-tables.md +58 -0
- package/assets/skills/office-spreadsheets/recipes/named-ranges.md +41 -0
- package/assets/skills/office-spreadsheets/recipes/number-formats.md +72 -0
- package/assets/skills/office-spreadsheets/recipes/sparklines.md +43 -0
- package/assets/skills/office-spreadsheets/references/charts.md +63 -0
- package/assets/skills/office-spreadsheets/references/domain/financial-models.md +40 -0
- package/assets/skills/office-spreadsheets/references/domain/healthcare.md +22 -0
- package/assets/skills/office-spreadsheets/references/domain/marketing.md +28 -0
- package/assets/skills/office-spreadsheets/references/domain/research.md +22 -0
- package/assets/skills/office-spreadsheets/references/formula-patterns.md +78 -0
- package/assets/skills/office-spreadsheets/references/style-guidelines.md +69 -0
- package/assets/skills/office-spreadsheets/references/workbook-structure.md +60 -0
- package/assets/skills/office-spreadsheets/routing/google-sheets.md +36 -0
- package/assets/skills/office-spreadsheets/tasks/analyze-workbook.md +37 -0
- package/assets/skills/office-spreadsheets/tasks/annotations.md +51 -0
- package/assets/skills/office-spreadsheets/tasks/create-workbook.md +66 -0
- package/assets/skills/office-spreadsheets/tasks/diff-workbooks.md +38 -0
- package/assets/skills/office-spreadsheets/tasks/edit-workbook.md +69 -0
- package/assets/skills/office-spreadsheets/tasks/inspect-workbook.md +38 -0
- package/assets/skills/office-spreadsheets/tasks/recalc-and-check.md +57 -0
- package/assets/skills/shared/annotation-recipes.md +252 -0
- package/assets/skills/shared/check_runtime.py +253 -0
- package/assets/skills/shared/common-workflow.md +139 -0
- package/assets/skills/shared/scripts/ensure_raster_image.py +187 -0
- package/dist/client.js +4 -4
- package/dist/client.js.map +1 -1
- package/dist/index.js +239 -89
- package/package.json +1 -1
- package/postinstall.mjs +92 -82
- package/python/office_cli.py +171 -246
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
"""Draft slide preview: rasterise a .pptx into one PNG per slide without Office.
|
|
2
|
+
|
|
3
|
+
Purpose: `render_slides.py` needs pixels to look at, and a machine may have no
|
|
4
|
+
office renderer at all. This module reads the package with
|
|
5
|
+
python-pptx and redraws every shape onto a PyMuPDF page: rectangles, text at
|
|
6
|
+
its declared size, table grids, images, and a bar/line/pie sketch of charts.
|
|
7
|
+
It then rasterises each page to PNG.
|
|
8
|
+
|
|
9
|
+
What it proves and what it does not:
|
|
10
|
+
* proves geometry — what sits where, what collides, what leaves the slide,
|
|
11
|
+
and which text boxes ran out of room (PyMuPDF tells us when a textbox
|
|
12
|
+
truncates);
|
|
13
|
+
* does NOT prove typography — fonts fall back to a built-in face and theme
|
|
14
|
+
colours/fills that python-pptx cannot resolve are approximated.
|
|
15
|
+
|
|
16
|
+
Exit codes: 0 ok, 2 unreadable input, 3 nothing rasterised.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import json
|
|
22
|
+
import sys
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import Any
|
|
25
|
+
|
|
26
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
27
|
+
|
|
28
|
+
try: # PyMuPDF ships both names; prefer the current one
|
|
29
|
+
import pymupdf as fitz # noqa: E402
|
|
30
|
+
except ImportError: # pragma: no cover - older wheels only expose fitz
|
|
31
|
+
import fitz # noqa: E402
|
|
32
|
+
|
|
33
|
+
from ooxml_helpers import ( # noqa: E402
|
|
34
|
+
EMU_PER_INCH,
|
|
35
|
+
box,
|
|
36
|
+
default_size_pt,
|
|
37
|
+
estimated_text_height_in,
|
|
38
|
+
has_text_frame,
|
|
39
|
+
is_chart,
|
|
40
|
+
is_picture,
|
|
41
|
+
is_table,
|
|
42
|
+
iter_shapes,
|
|
43
|
+
open_presentation,
|
|
44
|
+
placeholder_kind,
|
|
45
|
+
run_size_pt,
|
|
46
|
+
slide_size_in,
|
|
47
|
+
text_of,
|
|
48
|
+
WIDE_CHAR,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
INK = (0.13, 0.15, 0.18)
|
|
52
|
+
MUTED = (0.42, 0.45, 0.50)
|
|
53
|
+
RULE = (0.80, 0.82, 0.85)
|
|
54
|
+
ACCENT = (0.16, 0.36, 0.62)
|
|
55
|
+
PANEL = (0.96, 0.97, 0.98)
|
|
56
|
+
CJK_FONT = "china-s"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _has_cjk(text: str) -> bool:
|
|
60
|
+
return any(WIDE_CHAR.match(char) for char in text)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _font_for(text: str, size: float) -> tuple[str, str | None]:
|
|
64
|
+
if _has_cjk(text):
|
|
65
|
+
return CJK_FONT, None
|
|
66
|
+
return ("helv" if size < 20 else "hebo"), None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _solid_rgb(shape) -> tuple[float, float, float] | None:
|
|
70
|
+
try:
|
|
71
|
+
from pptx.enum.dml import MSO_FILL
|
|
72
|
+
|
|
73
|
+
if shape.fill.type != MSO_FILL.SOLID:
|
|
74
|
+
return None
|
|
75
|
+
rgb = shape.fill.fore_color.rgb
|
|
76
|
+
return (rgb[0] / 255, rgb[1] / 255, rgb[2] / 255)
|
|
77
|
+
except Exception: # noqa: BLE001 - theme colours raise; preview just skips them
|
|
78
|
+
return None
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _text_rgb(shape) -> tuple[float, float, float]:
|
|
82
|
+
for paragraph in getattr(shape, "text_frame", None).paragraphs if has_text_frame(shape) else []:
|
|
83
|
+
for run in paragraph.runs:
|
|
84
|
+
try:
|
|
85
|
+
rgb = run.font.color.rgb
|
|
86
|
+
except Exception: # noqa: BLE001
|
|
87
|
+
continue
|
|
88
|
+
if rgb is not None:
|
|
89
|
+
return (rgb[0] / 255, rgb[1] / 255, rgb[2] / 255)
|
|
90
|
+
return INK
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _draw_text(page, rect: fitz.Rect, text: str, size: float, color, align: int = 0) -> float:
|
|
94
|
+
if not text.strip():
|
|
95
|
+
return 1.0
|
|
96
|
+
fontname, _ = _font_for(text, size)
|
|
97
|
+
try:
|
|
98
|
+
return page.insert_textbox(rect, text, fontsize=size, fontname=fontname, color=color, align=align, lineheight=1.22)
|
|
99
|
+
except Exception: # noqa: BLE001 - a bad font name must not kill the render
|
|
100
|
+
return page.insert_textbox(rect, text, fontsize=size, color=color, align=align)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _draw_shape(page, shape, slide_no: int, notes: list[dict[str, Any]]) -> None:
|
|
104
|
+
geometry = box(shape)
|
|
105
|
+
if geometry is None:
|
|
106
|
+
return
|
|
107
|
+
left, top, width, height = geometry
|
|
108
|
+
rect = fitz.Rect(left * 72, top * 72, (left + width) * 72, (top + height) * 72)
|
|
109
|
+
if rect.is_empty:
|
|
110
|
+
return
|
|
111
|
+
|
|
112
|
+
if is_picture(shape):
|
|
113
|
+
try:
|
|
114
|
+
page.insert_image(rect, stream=shape.image.blob, keep_proportion=False)
|
|
115
|
+
except Exception as exc: # noqa: BLE001
|
|
116
|
+
notes.append({"slide": slide_no, "shape": shape.name, "issue": f"picture not drawn: {exc}"})
|
|
117
|
+
return
|
|
118
|
+
|
|
119
|
+
if is_table(shape):
|
|
120
|
+
_draw_table(page, shape, rect, slide_no, notes)
|
|
121
|
+
return
|
|
122
|
+
|
|
123
|
+
if is_chart(shape):
|
|
124
|
+
_draw_chart(page, shape, rect, slide_no, notes)
|
|
125
|
+
return
|
|
126
|
+
|
|
127
|
+
fill = _solid_rgb(shape)
|
|
128
|
+
if fill is not None:
|
|
129
|
+
page.draw_rect(rect, color=RULE, fill=fill, width=0.6)
|
|
130
|
+
elif getattr(shape, "shape_type", None) is not None and shape.shape_type is not None:
|
|
131
|
+
page.draw_rect(rect, color=RULE, width=0.4, dashes="[2 2] 0")
|
|
132
|
+
|
|
133
|
+
if not has_text_frame(shape):
|
|
134
|
+
return
|
|
135
|
+
|
|
136
|
+
frame = shape.text_frame
|
|
137
|
+
inset = fitz.Rect(
|
|
138
|
+
rect.x0 + (frame.margin_left or 0) / EMU_PER_INCH * 72,
|
|
139
|
+
rect.y0 + (frame.margin_top or 0) / EMU_PER_INCH * 72,
|
|
140
|
+
rect.x1 - (frame.margin_right or 0) / EMU_PER_INCH * 72,
|
|
141
|
+
rect.y1 - (frame.margin_bottom or 0) / EMU_PER_INCH * 72,
|
|
142
|
+
)
|
|
143
|
+
text = text_of(shape)
|
|
144
|
+
if not text.strip():
|
|
145
|
+
return
|
|
146
|
+
size = run_size_pt(shape) or default_size_pt(shape)
|
|
147
|
+
color = _text_rgb(shape)
|
|
148
|
+
kind = (placeholder_kind(shape) or "").upper()
|
|
149
|
+
align = 1 if kind in {"CENTER_TITLE", "TITLE"} else 0
|
|
150
|
+
# Title-like placeholders inherit middle anchoring from the master; the
|
|
151
|
+
# preview would otherwise pin every title to the top of its box.
|
|
152
|
+
if kind in {"CENTER_TITLE", "TITLE", "SUBTITLE"}:
|
|
153
|
+
needed = estimated_text_height_in(shape) or 0.0
|
|
154
|
+
slack = inset.height - needed * 72
|
|
155
|
+
if slack > 4:
|
|
156
|
+
inset = fitz.Rect(inset.x0, inset.y0 + slack / 2, inset.x1, inset.y1)
|
|
157
|
+
spare = _draw_text(page, inset, text, size, color, align)
|
|
158
|
+
if spare < 0:
|
|
159
|
+
# PyMuPDF draws nothing at all when the text cannot fit. Grow the frame
|
|
160
|
+
# downward and retry so the preview shows the overflow instead of an
|
|
161
|
+
# empty box, and still report it.
|
|
162
|
+
grown = fitz.Rect(inset.x0, inset.y0, inset.x1, inset.y1 + abs(spare) + 24)
|
|
163
|
+
_draw_text(page, grown, text, size, color, align)
|
|
164
|
+
notes.append(
|
|
165
|
+
{
|
|
166
|
+
"slide": slide_no,
|
|
167
|
+
"shape": shape.name,
|
|
168
|
+
"issue": "text overflows its frame",
|
|
169
|
+
"fontPt": round(size, 1),
|
|
170
|
+
"overflowPt": round(-spare, 1),
|
|
171
|
+
}
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _draw_table(page, shape, rect: fitz.Rect, slide_no: int, notes: list[dict[str, Any]]) -> None:
|
|
176
|
+
table = shape.table
|
|
177
|
+
columns = len(table.columns)
|
|
178
|
+
rows = len(table.rows)
|
|
179
|
+
if not columns or not rows:
|
|
180
|
+
return
|
|
181
|
+
widths = [column.width or 0 for column in table.columns]
|
|
182
|
+
total_w = sum(widths) or 1
|
|
183
|
+
heights = [row.height or int(0.4 * EMU_PER_INCH) for row in table.rows]
|
|
184
|
+
total_h = sum(heights) or 1
|
|
185
|
+
x = rect.x0
|
|
186
|
+
y = rect.y0
|
|
187
|
+
for row_index in range(rows):
|
|
188
|
+
row_h = rect.height * (heights[row_index] / total_h)
|
|
189
|
+
x = rect.x0
|
|
190
|
+
for column_index in range(columns):
|
|
191
|
+
column_w = rect.width * (widths[column_index] / total_w)
|
|
192
|
+
cell_rect = fitz.Rect(x, y, x + column_w, y + row_h)
|
|
193
|
+
page.draw_rect(cell_rect, color=RULE, width=0.5, fill=PANEL if row_index == 0 else None)
|
|
194
|
+
cell = table.cell(row_index, column_index)
|
|
195
|
+
cell_text = (cell.text or "").strip()
|
|
196
|
+
if cell_text:
|
|
197
|
+
size = 10.0 if row_index else 10.5
|
|
198
|
+
inner = fitz.Rect(cell_rect.x0 + 4, cell_rect.y0 + 2, cell_rect.x1 - 4, cell_rect.y1 - 2)
|
|
199
|
+
spare = _draw_text(page, inner, cell_text, size, INK)
|
|
200
|
+
if spare < 0:
|
|
201
|
+
notes.append({"slide": slide_no, "shape": f"{shape.name}[{row_index},{column_index}]", "issue": "table cell clips"})
|
|
202
|
+
x += column_w
|
|
203
|
+
y += row_h
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _draw_chart(page, shape, rect: fitz.Rect, slide_no: int, notes: list[dict[str, Any]]) -> None:
|
|
207
|
+
chart = shape.chart
|
|
208
|
+
page.draw_rect(rect, color=RULE, fill=(1, 1, 1), width=0.8)
|
|
209
|
+
title = ""
|
|
210
|
+
try:
|
|
211
|
+
if chart.has_title and chart.chart_title.text_frame.text.strip():
|
|
212
|
+
title = chart.chart_title.text_frame.text.strip()
|
|
213
|
+
except Exception: # noqa: BLE001
|
|
214
|
+
title = ""
|
|
215
|
+
plot = fitz.Rect(rect.x0 + 18, rect.y0 + 12, rect.x1 - 14, rect.y1 - 26)
|
|
216
|
+
if title:
|
|
217
|
+
_draw_text(page, fitz.Rect(plot.x0, rect.y0 + 4, plot.x1, rect.y0 + 26), title, 11, INK)
|
|
218
|
+
plot = fitz.Rect(plot.x0, rect.y0 + 30, plot.x1, rect.y1 - 26)
|
|
219
|
+
|
|
220
|
+
try:
|
|
221
|
+
categories = [str(category) for category in chart.plots[0].categories]
|
|
222
|
+
except Exception: # noqa: BLE001
|
|
223
|
+
categories = []
|
|
224
|
+
series: list[tuple[str, list[float]]] = []
|
|
225
|
+
try:
|
|
226
|
+
for plot_series in chart.series:
|
|
227
|
+
values = [float(value) if value is not None else 0.0 for value in plot_series.values]
|
|
228
|
+
series.append((str(plot_series.name), values))
|
|
229
|
+
except Exception: # noqa: BLE001 - a broken chart cache must not kill the render
|
|
230
|
+
notes.append({"slide": slide_no, "shape": shape.name, "issue": "chart values unreadable"})
|
|
231
|
+
|
|
232
|
+
if not series or not categories:
|
|
233
|
+
_draw_text(page, plot, "chart data unavailable", 11, MUTED)
|
|
234
|
+
return
|
|
235
|
+
|
|
236
|
+
kind = str(getattr(chart, "chart_type", ""))
|
|
237
|
+
max_value = max([abs(value) for _, values in series for value in values] + [1.0])
|
|
238
|
+
baseline = plot.y1 - 16
|
|
239
|
+
|
|
240
|
+
if "PIE" in kind.upper() or "DOUGHNUT" in kind.upper():
|
|
241
|
+
values = series[0][1]
|
|
242
|
+
total = sum(abs(value) for value in values) or 1.0
|
|
243
|
+
center = fitz.Point((plot.x0 + plot.x1) / 2, (plot.y0 + baseline) / 2)
|
|
244
|
+
radius = min(plot.width, baseline - plot.y0) * 0.34
|
|
245
|
+
start = 0.0
|
|
246
|
+
shades = [0.25, 0.40, 0.55, 0.70, 0.85, 0.95]
|
|
247
|
+
for index, value in enumerate(values):
|
|
248
|
+
sweep = 360.0 * abs(value) / total
|
|
249
|
+
shade = shades[index % len(shades)]
|
|
250
|
+
page.draw_pie(center, radius, fitz.Point(radius * 1.0, 0), start, start + sweep, color=RULE, fill=(shade, shade, shade * 0.95 + 0.05), width=0.6)
|
|
251
|
+
start += sweep
|
|
252
|
+
return
|
|
253
|
+
|
|
254
|
+
if "LINE" in kind.upper():
|
|
255
|
+
step = plot.width / max(len(categories) - 1, 1)
|
|
256
|
+
for series_index, (_, values) in enumerate(series):
|
|
257
|
+
points = []
|
|
258
|
+
for index, value in enumerate(values):
|
|
259
|
+
x = plot.x0 + step * index
|
|
260
|
+
y = baseline - (abs(value) / max_value) * (baseline - plot.y0 - 6)
|
|
261
|
+
points.append(fitz.Point(x, y))
|
|
262
|
+
if len(points) > 1:
|
|
263
|
+
shade = 0.20 + 0.18 * series_index
|
|
264
|
+
page.draw_polyline(points, color=(shade, shade, shade + 0.1), width=1.6)
|
|
265
|
+
for point in points:
|
|
266
|
+
page.draw_circle(point, 2.0, color=ACCENT, fill=ACCENT)
|
|
267
|
+
_draw_categories(page, plot, baseline, categories)
|
|
268
|
+
return
|
|
269
|
+
|
|
270
|
+
group_width = plot.width / max(len(categories), 1)
|
|
271
|
+
bar_width = group_width * 0.6 / max(len(series), 1)
|
|
272
|
+
for index, category in enumerate(categories):
|
|
273
|
+
for series_index, (_, values) in enumerate(series):
|
|
274
|
+
if index >= len(values):
|
|
275
|
+
continue
|
|
276
|
+
value = abs(values[index])
|
|
277
|
+
bar_height = (value / max_value) * (baseline - plot.y0 - 8)
|
|
278
|
+
x0 = plot.x0 + group_width * index + group_width * 0.2 + bar_width * series_index
|
|
279
|
+
bar = fitz.Rect(x0, baseline - bar_height, x0 + bar_width * 0.92, baseline)
|
|
280
|
+
shade = 0.30 + 0.22 * series_index
|
|
281
|
+
page.draw_rect(bar, color=ACCENT, fill=(shade * 0.55, shade * 0.75, shade), width=0.5)
|
|
282
|
+
_draw_text(page, fitz.Rect(bar.x0 - 6, bar.y0 - 12, bar.x1 + 6, bar.y0), _short(values[index]), 7.5, MUTED, 1)
|
|
283
|
+
page.draw_line(fitz.Point(plot.x0, baseline), fitz.Point(plot.x1, baseline), color=RULE, width=0.8)
|
|
284
|
+
_draw_categories(page, plot, baseline, categories)
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _short(value: float) -> str:
|
|
288
|
+
return f"{value:g}"
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _draw_categories(page, plot: fitz.Rect, baseline: float, categories: list[str]) -> None:
|
|
292
|
+
group_width = plot.width / max(max(len(categories), 1), 1)
|
|
293
|
+
for index, category in enumerate(categories):
|
|
294
|
+
cell = fitz.Rect(
|
|
295
|
+
plot.x0 + group_width * index,
|
|
296
|
+
baseline + 2,
|
|
297
|
+
plot.x0 + group_width * (index + 1) - 2,
|
|
298
|
+
baseline + 18,
|
|
299
|
+
)
|
|
300
|
+
_draw_text(page, cell, category[:18], 7.5, MUTED, 1)
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def render_preview(pptx_path: str | Path, out_dir: str | Path, width_px: int = 1600) -> dict[str, Any]:
|
|
304
|
+
"""Rasterise every slide to `<out_dir>/slide-NN.png`; returns a JSON-able report."""
|
|
305
|
+
presentation = open_presentation(pptx_path)
|
|
306
|
+
destination = Path(out_dir)
|
|
307
|
+
destination.mkdir(parents=True, exist_ok=True)
|
|
308
|
+
slide_w_in, slide_h_in = slide_size_in(presentation)
|
|
309
|
+
page_w = slide_w_in * 72
|
|
310
|
+
page_h = slide_h_in * 72
|
|
311
|
+
zoom = max(1.0, width_px / page_w)
|
|
312
|
+
|
|
313
|
+
files: list[str] = []
|
|
314
|
+
notes: list[dict[str, Any]] = []
|
|
315
|
+
document = fitz.open()
|
|
316
|
+
try:
|
|
317
|
+
for slide_no, slide in enumerate(presentation.slides, start=1):
|
|
318
|
+
page = document.new_page(width=page_w, height=page_h)
|
|
319
|
+
page.draw_rect(fitz.Rect(0, 0, page_w, page_h), color=None, fill=(1, 1, 1))
|
|
320
|
+
try:
|
|
321
|
+
background = slide.background.fill
|
|
322
|
+
rgb = background.fore_color.rgb
|
|
323
|
+
page.draw_rect(
|
|
324
|
+
fitz.Rect(0, 0, page_w, page_h),
|
|
325
|
+
color=None,
|
|
326
|
+
fill=(rgb[0] / 255, rgb[1] / 255, rgb[2] / 255),
|
|
327
|
+
)
|
|
328
|
+
except Exception: # noqa: BLE001 - most decks inherit a white master
|
|
329
|
+
pass
|
|
330
|
+
for shape in iter_shapes(slide.shapes):
|
|
331
|
+
_draw_shape(page, shape, slide_no, notes)
|
|
332
|
+
page.insert_text(
|
|
333
|
+
fitz.Point(page_w - 54, page_h - 10),
|
|
334
|
+
f"{slide_no}",
|
|
335
|
+
fontsize=7,
|
|
336
|
+
fontname="helv",
|
|
337
|
+
color=MUTED,
|
|
338
|
+
)
|
|
339
|
+
png = destination / f"slide-{slide_no:02d}.png"
|
|
340
|
+
page.get_pixmap(matrix=fitz.Matrix(zoom, zoom)).save(str(png))
|
|
341
|
+
files.append(str(png.resolve()))
|
|
342
|
+
finally:
|
|
343
|
+
document.close()
|
|
344
|
+
|
|
345
|
+
report = {
|
|
346
|
+
"ok": True,
|
|
347
|
+
"engine": "draft-preview",
|
|
348
|
+
"fidelity": "geometry-only",
|
|
349
|
+
"note": "Shapes redrawn from OOXML; fonts and theme colours are approximations. Use it to judge placement, collisions and overflow, not typography.",
|
|
350
|
+
"slides": len(files),
|
|
351
|
+
"slideSize": [round(slide_w_in, 3), round(slide_h_in, 3)],
|
|
352
|
+
"files": files,
|
|
353
|
+
"overflow": notes,
|
|
354
|
+
}
|
|
355
|
+
(destination / "preview.json").write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
356
|
+
return report
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def main(argv: list[str] | None = None) -> int:
|
|
360
|
+
import argparse
|
|
361
|
+
|
|
362
|
+
parser = argparse.ArgumentParser(description="Rasterise a .pptx to PNGs using a draft geometry renderer (no Office required).")
|
|
363
|
+
parser.add_argument("presentation", help="path to the .pptx")
|
|
364
|
+
parser.add_argument("--out-dir", required=True, help="directory for slide-NN.png and preview.json")
|
|
365
|
+
parser.add_argument("--width", type=int, default=1600, help="output pixel width (default 1600)")
|
|
366
|
+
args = parser.parse_args(argv)
|
|
367
|
+
|
|
368
|
+
report = render_preview(args.presentation, args.out_dir, args.width)
|
|
369
|
+
json.dump(report, sys.stdout, ensure_ascii=False, indent=2)
|
|
370
|
+
sys.stdout.write("\n")
|
|
371
|
+
return 0 if report["slides"] else 3
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
if __name__ == "__main__":
|
|
375
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""Render a deck to PNGs, one per slide, using whatever engine is available.
|
|
2
|
+
|
|
3
|
+
Purpose: the verification loop needs pixels. Ask the plugin CLI first: it drives
|
|
4
|
+
Microsoft Office, WPS Office, or LibreOffice, whichever is installed. When none
|
|
5
|
+
is, fall back to the draft geometry renderer in `preview_render.py` and say so
|
|
6
|
+
loudly in the JSON — a draft preview proves placement, not typography.
|
|
7
|
+
|
|
8
|
+
Inputs may be a .pptx or a .pdf. Output is `<out-dir>/slide-NN.png` for every
|
|
9
|
+
page, plus `<out-dir>/render.json`.
|
|
10
|
+
|
|
11
|
+
Exit codes:
|
|
12
|
+
0 at least one page rendered
|
|
13
|
+
2 input missing or unreadable
|
|
14
|
+
3 no engine produced output
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import json
|
|
20
|
+
import shutil
|
|
21
|
+
import subprocess
|
|
22
|
+
import sys
|
|
23
|
+
import tempfile
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
|
|
26
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def office_cli() -> Path:
|
|
30
|
+
"""The plugin's helper CLI, which owns renderer discovery."""
|
|
31
|
+
return Path(__file__).resolve().parents[4] / "python" / "office_cli.py"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def rasterise_pdf(pdf_path: Path, out_dir: Path, dpi: int) -> list[str]:
|
|
35
|
+
try:
|
|
36
|
+
import pymupdf as fitz
|
|
37
|
+
except ImportError: # pragma: no cover
|
|
38
|
+
import fitz
|
|
39
|
+
|
|
40
|
+
files: list[str] = []
|
|
41
|
+
document = fitz.open(str(pdf_path))
|
|
42
|
+
try:
|
|
43
|
+
zoom = dpi / 72.0
|
|
44
|
+
for index, page in enumerate(document, start=1):
|
|
45
|
+
png = out_dir / f"slide-{index:02d}.png"
|
|
46
|
+
page.get_pixmap(matrix=fitz.Matrix(zoom, zoom)).save(str(png))
|
|
47
|
+
files.append(str(png))
|
|
48
|
+
finally:
|
|
49
|
+
document.close()
|
|
50
|
+
return files
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def render_with_office(source: Path, out_dir: Path, dpi: int) -> dict:
|
|
54
|
+
"""Export through the CLI, which tries every installed office suite."""
|
|
55
|
+
scratch = Path(tempfile.mkdtemp(prefix="dsh-render-"))
|
|
56
|
+
try:
|
|
57
|
+
completed = subprocess.run(
|
|
58
|
+
[sys.executable, str(office_cli()), "pptx", "render",
|
|
59
|
+
"--input", str(source), "--out-dir", str(scratch), "--dpi", str(dpi)],
|
|
60
|
+
capture_output=True, text=True, timeout=300, check=False,
|
|
61
|
+
)
|
|
62
|
+
if completed.returncode != 0:
|
|
63
|
+
raise RuntimeError((completed.stderr or completed.stdout or "render failed").strip()[:400])
|
|
64
|
+
report = json.loads(completed.stdout)
|
|
65
|
+
files: list[str] = []
|
|
66
|
+
for index, page in enumerate(report.get("pages") or [], start=1):
|
|
67
|
+
target = out_dir / f"slide-{index:02d}.png"
|
|
68
|
+
shutil.move(page, target)
|
|
69
|
+
files.append(str(target))
|
|
70
|
+
return {
|
|
71
|
+
"ok": bool(files),
|
|
72
|
+
"engine": report.get("renderer") or "office",
|
|
73
|
+
"fidelity": "faithful",
|
|
74
|
+
"slides": len(files),
|
|
75
|
+
"dpi": dpi,
|
|
76
|
+
"files": files,
|
|
77
|
+
}
|
|
78
|
+
finally:
|
|
79
|
+
shutil.rmtree(scratch, ignore_errors=True)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def render(input_path: str | Path, out_dir: str | Path, dpi: int = 110, engine: str = "auto", width: int = 1600) -> dict:
|
|
83
|
+
source = Path(input_path)
|
|
84
|
+
destination = Path(out_dir)
|
|
85
|
+
if not source.is_file():
|
|
86
|
+
return {"ok": False, "error": {"code": "input_missing", "message": f"no such file: {source}"}}
|
|
87
|
+
destination.mkdir(parents=True, exist_ok=True)
|
|
88
|
+
|
|
89
|
+
if source.suffix.lower() == ".pdf":
|
|
90
|
+
files = rasterise_pdf(source, destination, dpi)
|
|
91
|
+
return {"ok": bool(files), "engine": "pdf-raster", "fidelity": "faithful", "slides": len(files), "dpi": dpi, "files": files}
|
|
92
|
+
|
|
93
|
+
notes: list[str] = []
|
|
94
|
+
if engine in {"auto", "office"}:
|
|
95
|
+
try:
|
|
96
|
+
return render_with_office(source, destination, dpi)
|
|
97
|
+
except Exception as exc: # noqa: BLE001 - fall through to the draft engine
|
|
98
|
+
notes.append(f"office render failed: {exc}")
|
|
99
|
+
|
|
100
|
+
if engine == "office":
|
|
101
|
+
return {"ok": False, "engine": "office", "notes": notes, "error": {"code": "no_engine", "message": "no Microsoft Office, WPS Office, or LibreOffice renderer is available"}}
|
|
102
|
+
|
|
103
|
+
from preview_render import render_preview
|
|
104
|
+
|
|
105
|
+
report = render_preview(source, destination, width)
|
|
106
|
+
report["notes"] = notes + [
|
|
107
|
+
"Draft preview: geometry is exact, type is approximate. Treat colours and fonts as indicative only.",
|
|
108
|
+
]
|
|
109
|
+
report["files"] = [str(Path(item).resolve()) for item in report["files"]]
|
|
110
|
+
return report
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def main(argv: list[str] | None = None) -> int:
|
|
114
|
+
import argparse
|
|
115
|
+
|
|
116
|
+
parser = argparse.ArgumentParser(description="Render a .pptx or .pdf into one PNG per page.")
|
|
117
|
+
parser.add_argument("--input", required=True, help="deck or PDF to render")
|
|
118
|
+
parser.add_argument("--out-dir", required=True, help="directory for slide-NN.png and render.json")
|
|
119
|
+
parser.add_argument("--dpi", type=int, default=110, help="raster dpi when a real renderer is used (default 110)")
|
|
120
|
+
parser.add_argument("--width", type=int, default=1600, help="pixel width for the draft renderer (default 1600)")
|
|
121
|
+
parser.add_argument("--engine", choices=("auto", "office", "draft"), default="auto", help="force an engine (default auto)")
|
|
122
|
+
parser.add_argument("--json-out", help="also write the report here")
|
|
123
|
+
args = parser.parse_args(argv)
|
|
124
|
+
|
|
125
|
+
report = render(args.input, args.out_dir, args.dpi, args.engine, args.width)
|
|
126
|
+
out_dir = Path(args.out_dir)
|
|
127
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
128
|
+
(out_dir / "render.json").write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
129
|
+
if args.json_out:
|
|
130
|
+
Path(args.json_out).write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
131
|
+
json.dump(report, sys.stdout, ensure_ascii=False, indent=2)
|
|
132
|
+
sys.stdout.write("\n")
|
|
133
|
+
if report.get("ok"):
|
|
134
|
+
return 0
|
|
135
|
+
return 2 if report.get("error", {}).get("code") == "input_missing" else 3
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
if __name__ == "__main__":
|
|
139
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"""Replace text inside runs of an existing deck.
|
|
2
|
+
|
|
3
|
+
Run-level by design: a match only counts when the whole `find` string lives in a
|
|
4
|
+
single run. Text split across runs by earlier formatting is *not* merged, which
|
|
5
|
+
keeps formatting intact and makes the outcome predictable — the tool reports
|
|
6
|
+
what it matched, and reports what it could not match.
|
|
7
|
+
|
|
8
|
+
Usage
|
|
9
|
+
-----
|
|
10
|
+
<python> replace_text.py --input deck.pptx --find "Q4 2025" --replace "Q4 2026"
|
|
11
|
+
<python> replace_text.py --input deck.pptx --spec replacements.json --out fixed.pptx
|
|
12
|
+
|
|
13
|
+
Exit codes
|
|
14
|
+
----------
|
|
15
|
+
0 every requested pattern was applied at least once
|
|
16
|
+
1 a pattern matched nothing (the file is still written; check the report)
|
|
17
|
+
2 bad arguments, unreadable input or invalid spec
|
|
18
|
+
3 the output file could not be written
|
|
19
|
+
|
|
20
|
+
The JSON report on stdout is `{ok, input, output, total, slides[], unmatched[]}`.
|
|
21
|
+
"""
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import argparse
|
|
25
|
+
import json
|
|
26
|
+
import sys
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _emit(payload: dict, code: int = 0) -> int:
|
|
31
|
+
print(json.dumps(payload, ensure_ascii=False, indent=2))
|
|
32
|
+
return code
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _load_spec(path: str | None) -> list[dict]:
|
|
36
|
+
if not path:
|
|
37
|
+
return []
|
|
38
|
+
data = json.loads(Path(path).read_text(encoding="utf-8"))
|
|
39
|
+
if isinstance(data, dict):
|
|
40
|
+
entries = data.get("replacements") or []
|
|
41
|
+
elif isinstance(data, list):
|
|
42
|
+
entries = data
|
|
43
|
+
else:
|
|
44
|
+
raise ValueError("spec must be an object with 'replacements' or a list")
|
|
45
|
+
resolved = []
|
|
46
|
+
for entry in entries:
|
|
47
|
+
if not isinstance(entry, dict) or "find" not in entry:
|
|
48
|
+
raise ValueError(f"replacement needs a 'find' key: {entry!r}")
|
|
49
|
+
resolved.append(
|
|
50
|
+
{
|
|
51
|
+
"find": str(entry["find"]),
|
|
52
|
+
"replace": str(entry.get("replace", "")),
|
|
53
|
+
"slide": entry.get("slide"),
|
|
54
|
+
"match_case": bool(entry.get("match_case", True)),
|
|
55
|
+
}
|
|
56
|
+
)
|
|
57
|
+
return resolved
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _frames(slide):
|
|
61
|
+
for shape in slide.shapes:
|
|
62
|
+
if shape.has_text_frame:
|
|
63
|
+
yield shape.text_frame
|
|
64
|
+
if getattr(shape, "has_table", False) and shape.has_table:
|
|
65
|
+
for row in shape.table.rows:
|
|
66
|
+
for cell in row.cells:
|
|
67
|
+
yield cell.text_frame
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def replace_in_slide(slide, rules: list[dict]) -> list[dict]:
|
|
71
|
+
hits = []
|
|
72
|
+
for frame in _frames(slide):
|
|
73
|
+
for paragraph in frame.paragraphs:
|
|
74
|
+
for run in paragraph.runs:
|
|
75
|
+
original = run.text
|
|
76
|
+
updated = original
|
|
77
|
+
for rule in rules:
|
|
78
|
+
needle = rule["find"] if rule["match_case"] else rule["find"].lower()
|
|
79
|
+
haystack = updated if rule["match_case"] else updated.lower()
|
|
80
|
+
if needle and needle in haystack:
|
|
81
|
+
updated = updated.replace(rule["find"], rule["replace"])
|
|
82
|
+
hits.append({"find": rule["find"], "before": original, "after": updated})
|
|
83
|
+
if updated != original:
|
|
84
|
+
run.text = updated
|
|
85
|
+
return hits
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def main() -> int:
|
|
89
|
+
parser = argparse.ArgumentParser(
|
|
90
|
+
description="Replace text inside runs of a .pptx (never across runs).",
|
|
91
|
+
epilog="Exit codes: 0 all patterns applied, 1 a pattern matched nothing, 2 bad input, 3 write failure.",
|
|
92
|
+
)
|
|
93
|
+
parser.add_argument("--input", required=True, help="source deck")
|
|
94
|
+
parser.add_argument("--find", action="append", default=[], help="text to find (repeatable)")
|
|
95
|
+
parser.add_argument("--replace", action="append", default=[], help="replacement (repeatable, pairs with --find)")
|
|
96
|
+
parser.add_argument("--spec", help="JSON file: {replacements:[{find, replace, slide?, match_case?}]}")
|
|
97
|
+
parser.add_argument("--out", help="output deck (default: <input>-edited.pptx)")
|
|
98
|
+
args = parser.parse_args()
|
|
99
|
+
|
|
100
|
+
rules: list[dict] = []
|
|
101
|
+
try:
|
|
102
|
+
for index, needle in enumerate(args.find):
|
|
103
|
+
rules.append(
|
|
104
|
+
{
|
|
105
|
+
"find": needle,
|
|
106
|
+
"replace": args.replace[index] if index < len(args.replace) else "",
|
|
107
|
+
"slide": None,
|
|
108
|
+
"match_case": True,
|
|
109
|
+
}
|
|
110
|
+
)
|
|
111
|
+
rules.extend(_load_spec(args.spec))
|
|
112
|
+
except (OSError, ValueError, json.JSONDecodeError) as error:
|
|
113
|
+
return _emit({"ok": False, "error": f"bad spec: {error}"}, 2)
|
|
114
|
+
|
|
115
|
+
if not rules:
|
|
116
|
+
return _emit({"ok": False, "error": "nothing to replace: pass --find/--replace or --spec"}, 2)
|
|
117
|
+
|
|
118
|
+
source = Path(args.input)
|
|
119
|
+
if not source.is_file():
|
|
120
|
+
return _emit({"ok": False, "error": f"no such file: {source}"}, 2)
|
|
121
|
+
output = Path(args.out) if args.out else source.with_name(f"{source.stem}-edited{source.suffix}")
|
|
122
|
+
|
|
123
|
+
try:
|
|
124
|
+
from pptx import Presentation
|
|
125
|
+
except ImportError as error:
|
|
126
|
+
return _emit({"ok": False, "error": f"python-pptx is required: {error}"}, 2)
|
|
127
|
+
|
|
128
|
+
try:
|
|
129
|
+
prs = Presentation(str(source))
|
|
130
|
+
except Exception as error: # noqa: BLE001 - report, never traceback at the user
|
|
131
|
+
return _emit({"ok": False, "error": f"cannot open {source}: {error}"}, 2)
|
|
132
|
+
|
|
133
|
+
slides_report = []
|
|
134
|
+
total = 0
|
|
135
|
+
matched_patterns: set[str] = set()
|
|
136
|
+
for index, slide in enumerate(prs.slides, start=1):
|
|
137
|
+
applicable = [rule for rule in rules if rule["slide"] in (None, index)]
|
|
138
|
+
hits = replace_in_slide(slide, applicable)
|
|
139
|
+
for hit in hits:
|
|
140
|
+
matched_patterns.add(hit["find"])
|
|
141
|
+
total += len(hits)
|
|
142
|
+
if hits:
|
|
143
|
+
slides_report.append({"slide": index, "replacements": len(hits), "details": hits[:10]})
|
|
144
|
+
|
|
145
|
+
unmatched = sorted({rule["find"] for rule in rules} - matched_patterns)
|
|
146
|
+
|
|
147
|
+
try:
|
|
148
|
+
output.parent.mkdir(parents=True, exist_ok=True)
|
|
149
|
+
prs.save(str(output))
|
|
150
|
+
except Exception as error: # noqa: BLE001
|
|
151
|
+
return _emit({"ok": False, "error": f"cannot write {output}: {error}"}, 3)
|
|
152
|
+
|
|
153
|
+
report = {
|
|
154
|
+
"schema": "replace_text/1",
|
|
155
|
+
"ok": not unmatched,
|
|
156
|
+
"input": str(source),
|
|
157
|
+
"output": str(output),
|
|
158
|
+
"rules": len(rules),
|
|
159
|
+
"total": total,
|
|
160
|
+
"slides": slides_report,
|
|
161
|
+
"unmatched": unmatched,
|
|
162
|
+
"note": "matches only inside a single run; text split by formatting is left alone",
|
|
163
|
+
}
|
|
164
|
+
return _emit(report, 0 if not unmatched else 1)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
if __name__ == "__main__":
|
|
168
|
+
sys.exit(main())
|