dsh-office-kit 0.2.0 → 0.3.1
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 +99 -0
- package/assets/skills/office-documents/scripts/a11y_audit.py +201 -0
- package/assets/skills/office-documents/scripts/accept_tracked_changes.py +158 -0
- package/assets/skills/office-documents/scripts/add_tracked_replacements.py +155 -0
- package/assets/skills/office-documents/scripts/append_blocks.py +115 -0
- package/assets/skills/office-documents/scripts/apply_template_styles.py +111 -0
- package/assets/skills/office-documents/scripts/captions_and_crossrefs.py +109 -0
- package/assets/skills/office-documents/scripts/comments_add.py +143 -0
- package/assets/skills/office-documents/scripts/comments_apply_patch.py +121 -0
- package/assets/skills/office-documents/scripts/comments_extract.py +92 -0
- package/assets/skills/office-documents/scripts/comments_strip.py +81 -0
- package/assets/skills/office-documents/scripts/content_controls.py +199 -0
- package/assets/skills/office-documents/scripts/core_properties.py +131 -0
- package/assets/skills/office-documents/scripts/docx_kit.py +594 -0
- package/assets/skills/office-documents/scripts/docx_ooxml_patch.py +232 -0
- package/assets/skills/office-documents/scripts/docx_table_to_csv.py +106 -0
- package/assets/skills/office-documents/scripts/fields_materialize.py +73 -0
- package/assets/skills/office-documents/scripts/fields_report.py +66 -0
- package/assets/skills/office-documents/scripts/flatten_ref_fields.py +69 -0
- package/assets/skills/office-documents/scripts/footnotes_report.py +84 -0
- package/assets/skills/office-documents/scripts/google_docs_title_sanitize.py +103 -0
- package/assets/skills/office-documents/scripts/heading_audit.py +88 -0
- package/assets/skills/office-documents/scripts/images_audit.py +87 -0
- package/assets/skills/office-documents/scripts/insert_note.py +133 -0
- package/assets/skills/office-documents/scripts/insert_ref_fields.py +97 -0
- package/assets/skills/office-documents/scripts/insert_toc.py +91 -0
- package/assets/skills/office-documents/scripts/internal_nav.py +153 -0
- package/assets/skills/office-documents/scripts/make_fixtures.py +176 -0
- package/assets/skills/office-documents/scripts/merge_docx_append.py +246 -0
- package/assets/skills/office-documents/scripts/privacy_scrub.py +138 -0
- package/assets/skills/office-documents/scripts/redact_docx.py +120 -0
- package/assets/skills/office-documents/scripts/render_and_diff.py +108 -0
- package/assets/skills/office-documents/scripts/replace_text.py +161 -0
- package/assets/skills/office-documents/scripts/section_audit.py +98 -0
- package/assets/skills/office-documents/scripts/set_protection.py +99 -0
- package/assets/skills/office-documents/scripts/style_lint.py +118 -0
- package/assets/skills/office-documents/scripts/style_normalize.py +110 -0
- package/assets/skills/office-documents/scripts/table_geometry.py +174 -0
- package/assets/skills/office-documents/scripts/watermark_add.py +55 -0
- package/assets/skills/office-documents/scripts/watermark_audit_remove.py +74 -0
- package/assets/skills/office-documents/scripts/xlsx_to_docx_table.py +124 -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 +143 -0
- package/assets/skills/office-presentations/container_tools/core_properties.py +127 -0
- package/assets/skills/office-presentations/container_tools/create_montage.py +133 -0
- package/assets/skills/office-presentations/container_tools/inspect_presentation_layout_geometry.py +292 -0
- package/assets/skills/office-presentations/container_tools/inspect_presentation_package_integrity.py +164 -0
- package/assets/skills/office-presentations/container_tools/native_chart_title_gate.py +151 -0
- package/assets/skills/office-presentations/container_tools/native_quantitative_chart_gate.py +206 -0
- package/assets/skills/office-presentations/container_tools/native_table_arithmetic_gate.py +219 -0
- package/assets/skills/office-presentations/container_tools/ooxml_helpers.py +352 -0
- package/assets/skills/office-presentations/container_tools/preview_render.py +382 -0
- package/assets/skills/office-presentations/container_tools/render_slides.py +146 -0
- package/assets/skills/office-presentations/container_tools/replace_text.py +175 -0
- package/assets/skills/office-presentations/container_tools/template_source_fidelity_gate.py +236 -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 +183 -0
- package/assets/skills/office-presentations/template_following_scripts/inspect_template_deck.py +161 -0
- package/assets/skills/office-presentations/template_following_scripts/make_contact_sheet.py +118 -0
- package/assets/skills/office-presentations/template_following_scripts/prepare_template_starter_deck.py +246 -0
- package/assets/skills/office-presentations/template_following_scripts/validate_template_plan.py +192 -0
- package/assets/skills/office-spreadsheets/SKILL.md +105 -101
- package/assets/skills/office-spreadsheets/container_tools/add_chart.py +243 -0
- package/assets/skills/office-spreadsheets/container_tools/add_image.py +114 -0
- package/assets/skills/office-spreadsheets/container_tools/append_rows.py +129 -0
- package/assets/skills/office-spreadsheets/container_tools/diff_workbooks.py +313 -0
- package/assets/skills/office-spreadsheets/container_tools/inspect_workbook.py +405 -0
- package/assets/skills/office-spreadsheets/container_tools/recalc_gate.py +287 -0
- package/assets/skills/office-spreadsheets/container_tools/set_cells.py +142 -0
- package/assets/skills/office-spreadsheets/container_tools/sheet_ops.py +141 -0
- package/assets/skills/office-spreadsheets/container_tools/style_cells.py +140 -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 +260 -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 +246 -89
- package/package.json +1 -1
- package/postinstall.mjs +92 -82
- package/python/office_cli.py +213 -254
|
@@ -0,0 +1,382 @@
|
|
|
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
|
+
# Windows consoles default to a legacy code page; every payload is UTF-8 JSON.
|
|
52
|
+
for _stream in (sys.stdout, sys.stderr):
|
|
53
|
+
try:
|
|
54
|
+
_stream.reconfigure(encoding="utf-8", errors="replace")
|
|
55
|
+
except (AttributeError, ValueError):
|
|
56
|
+
pass
|
|
57
|
+
|
|
58
|
+
INK = (0.13, 0.15, 0.18)
|
|
59
|
+
MUTED = (0.42, 0.45, 0.50)
|
|
60
|
+
RULE = (0.80, 0.82, 0.85)
|
|
61
|
+
ACCENT = (0.16, 0.36, 0.62)
|
|
62
|
+
PANEL = (0.96, 0.97, 0.98)
|
|
63
|
+
CJK_FONT = "china-s"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _has_cjk(text: str) -> bool:
|
|
67
|
+
return any(WIDE_CHAR.match(char) for char in text)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _font_for(text: str, size: float) -> tuple[str, str | None]:
|
|
71
|
+
if _has_cjk(text):
|
|
72
|
+
return CJK_FONT, None
|
|
73
|
+
return ("helv" if size < 20 else "hebo"), None
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _solid_rgb(shape) -> tuple[float, float, float] | None:
|
|
77
|
+
try:
|
|
78
|
+
from pptx.enum.dml import MSO_FILL
|
|
79
|
+
|
|
80
|
+
if shape.fill.type != MSO_FILL.SOLID:
|
|
81
|
+
return None
|
|
82
|
+
rgb = shape.fill.fore_color.rgb
|
|
83
|
+
return (rgb[0] / 255, rgb[1] / 255, rgb[2] / 255)
|
|
84
|
+
except Exception: # noqa: BLE001 - theme colours raise; preview just skips them
|
|
85
|
+
return None
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _text_rgb(shape) -> tuple[float, float, float]:
|
|
89
|
+
for paragraph in getattr(shape, "text_frame", None).paragraphs if has_text_frame(shape) else []:
|
|
90
|
+
for run in paragraph.runs:
|
|
91
|
+
try:
|
|
92
|
+
rgb = run.font.color.rgb
|
|
93
|
+
except Exception: # noqa: BLE001
|
|
94
|
+
continue
|
|
95
|
+
if rgb is not None:
|
|
96
|
+
return (rgb[0] / 255, rgb[1] / 255, rgb[2] / 255)
|
|
97
|
+
return INK
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _draw_text(page, rect: fitz.Rect, text: str, size: float, color, align: int = 0) -> float:
|
|
101
|
+
if not text.strip():
|
|
102
|
+
return 1.0
|
|
103
|
+
fontname, _ = _font_for(text, size)
|
|
104
|
+
try:
|
|
105
|
+
return page.insert_textbox(rect, text, fontsize=size, fontname=fontname, color=color, align=align, lineheight=1.22)
|
|
106
|
+
except Exception: # noqa: BLE001 - a bad font name must not kill the render
|
|
107
|
+
return page.insert_textbox(rect, text, fontsize=size, color=color, align=align)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _draw_shape(page, shape, slide_no: int, notes: list[dict[str, Any]]) -> None:
|
|
111
|
+
geometry = box(shape)
|
|
112
|
+
if geometry is None:
|
|
113
|
+
return
|
|
114
|
+
left, top, width, height = geometry
|
|
115
|
+
rect = fitz.Rect(left * 72, top * 72, (left + width) * 72, (top + height) * 72)
|
|
116
|
+
if rect.is_empty:
|
|
117
|
+
return
|
|
118
|
+
|
|
119
|
+
if is_picture(shape):
|
|
120
|
+
try:
|
|
121
|
+
page.insert_image(rect, stream=shape.image.blob, keep_proportion=False)
|
|
122
|
+
except Exception as exc: # noqa: BLE001
|
|
123
|
+
notes.append({"slide": slide_no, "shape": shape.name, "issue": f"picture not drawn: {exc}"})
|
|
124
|
+
return
|
|
125
|
+
|
|
126
|
+
if is_table(shape):
|
|
127
|
+
_draw_table(page, shape, rect, slide_no, notes)
|
|
128
|
+
return
|
|
129
|
+
|
|
130
|
+
if is_chart(shape):
|
|
131
|
+
_draw_chart(page, shape, rect, slide_no, notes)
|
|
132
|
+
return
|
|
133
|
+
|
|
134
|
+
fill = _solid_rgb(shape)
|
|
135
|
+
if fill is not None:
|
|
136
|
+
page.draw_rect(rect, color=RULE, fill=fill, width=0.6)
|
|
137
|
+
elif getattr(shape, "shape_type", None) is not None and shape.shape_type is not None:
|
|
138
|
+
page.draw_rect(rect, color=RULE, width=0.4, dashes="[2 2] 0")
|
|
139
|
+
|
|
140
|
+
if not has_text_frame(shape):
|
|
141
|
+
return
|
|
142
|
+
|
|
143
|
+
frame = shape.text_frame
|
|
144
|
+
inset = fitz.Rect(
|
|
145
|
+
rect.x0 + (frame.margin_left or 0) / EMU_PER_INCH * 72,
|
|
146
|
+
rect.y0 + (frame.margin_top or 0) / EMU_PER_INCH * 72,
|
|
147
|
+
rect.x1 - (frame.margin_right or 0) / EMU_PER_INCH * 72,
|
|
148
|
+
rect.y1 - (frame.margin_bottom or 0) / EMU_PER_INCH * 72,
|
|
149
|
+
)
|
|
150
|
+
text = text_of(shape)
|
|
151
|
+
if not text.strip():
|
|
152
|
+
return
|
|
153
|
+
size = run_size_pt(shape) or default_size_pt(shape)
|
|
154
|
+
color = _text_rgb(shape)
|
|
155
|
+
kind = (placeholder_kind(shape) or "").upper()
|
|
156
|
+
align = 1 if kind in {"CENTER_TITLE", "TITLE"} else 0
|
|
157
|
+
# Title-like placeholders inherit middle anchoring from the master; the
|
|
158
|
+
# preview would otherwise pin every title to the top of its box.
|
|
159
|
+
if kind in {"CENTER_TITLE", "TITLE", "SUBTITLE"}:
|
|
160
|
+
needed = estimated_text_height_in(shape) or 0.0
|
|
161
|
+
slack = inset.height - needed * 72
|
|
162
|
+
if slack > 4:
|
|
163
|
+
inset = fitz.Rect(inset.x0, inset.y0 + slack / 2, inset.x1, inset.y1)
|
|
164
|
+
spare = _draw_text(page, inset, text, size, color, align)
|
|
165
|
+
if spare < 0:
|
|
166
|
+
# PyMuPDF draws nothing at all when the text cannot fit. Grow the frame
|
|
167
|
+
# downward and retry so the preview shows the overflow instead of an
|
|
168
|
+
# empty box, and still report it.
|
|
169
|
+
grown = fitz.Rect(inset.x0, inset.y0, inset.x1, inset.y1 + abs(spare) + 24)
|
|
170
|
+
_draw_text(page, grown, text, size, color, align)
|
|
171
|
+
notes.append(
|
|
172
|
+
{
|
|
173
|
+
"slide": slide_no,
|
|
174
|
+
"shape": shape.name,
|
|
175
|
+
"issue": "text overflows its frame",
|
|
176
|
+
"fontPt": round(size, 1),
|
|
177
|
+
"overflowPt": round(-spare, 1),
|
|
178
|
+
}
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _draw_table(page, shape, rect: fitz.Rect, slide_no: int, notes: list[dict[str, Any]]) -> None:
|
|
183
|
+
table = shape.table
|
|
184
|
+
columns = len(table.columns)
|
|
185
|
+
rows = len(table.rows)
|
|
186
|
+
if not columns or not rows:
|
|
187
|
+
return
|
|
188
|
+
widths = [column.width or 0 for column in table.columns]
|
|
189
|
+
total_w = sum(widths) or 1
|
|
190
|
+
heights = [row.height or int(0.4 * EMU_PER_INCH) for row in table.rows]
|
|
191
|
+
total_h = sum(heights) or 1
|
|
192
|
+
x = rect.x0
|
|
193
|
+
y = rect.y0
|
|
194
|
+
for row_index in range(rows):
|
|
195
|
+
row_h = rect.height * (heights[row_index] / total_h)
|
|
196
|
+
x = rect.x0
|
|
197
|
+
for column_index in range(columns):
|
|
198
|
+
column_w = rect.width * (widths[column_index] / total_w)
|
|
199
|
+
cell_rect = fitz.Rect(x, y, x + column_w, y + row_h)
|
|
200
|
+
page.draw_rect(cell_rect, color=RULE, width=0.5, fill=PANEL if row_index == 0 else None)
|
|
201
|
+
cell = table.cell(row_index, column_index)
|
|
202
|
+
cell_text = (cell.text or "").strip()
|
|
203
|
+
if cell_text:
|
|
204
|
+
size = 10.0 if row_index else 10.5
|
|
205
|
+
inner = fitz.Rect(cell_rect.x0 + 4, cell_rect.y0 + 2, cell_rect.x1 - 4, cell_rect.y1 - 2)
|
|
206
|
+
spare = _draw_text(page, inner, cell_text, size, INK)
|
|
207
|
+
if spare < 0:
|
|
208
|
+
notes.append({"slide": slide_no, "shape": f"{shape.name}[{row_index},{column_index}]", "issue": "table cell clips"})
|
|
209
|
+
x += column_w
|
|
210
|
+
y += row_h
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _draw_chart(page, shape, rect: fitz.Rect, slide_no: int, notes: list[dict[str, Any]]) -> None:
|
|
214
|
+
chart = shape.chart
|
|
215
|
+
page.draw_rect(rect, color=RULE, fill=(1, 1, 1), width=0.8)
|
|
216
|
+
title = ""
|
|
217
|
+
try:
|
|
218
|
+
if chart.has_title and chart.chart_title.text_frame.text.strip():
|
|
219
|
+
title = chart.chart_title.text_frame.text.strip()
|
|
220
|
+
except Exception: # noqa: BLE001
|
|
221
|
+
title = ""
|
|
222
|
+
plot = fitz.Rect(rect.x0 + 18, rect.y0 + 12, rect.x1 - 14, rect.y1 - 26)
|
|
223
|
+
if title:
|
|
224
|
+
_draw_text(page, fitz.Rect(plot.x0, rect.y0 + 4, plot.x1, rect.y0 + 26), title, 11, INK)
|
|
225
|
+
plot = fitz.Rect(plot.x0, rect.y0 + 30, plot.x1, rect.y1 - 26)
|
|
226
|
+
|
|
227
|
+
try:
|
|
228
|
+
categories = [str(category) for category in chart.plots[0].categories]
|
|
229
|
+
except Exception: # noqa: BLE001
|
|
230
|
+
categories = []
|
|
231
|
+
series: list[tuple[str, list[float]]] = []
|
|
232
|
+
try:
|
|
233
|
+
for plot_series in chart.series:
|
|
234
|
+
values = [float(value) if value is not None else 0.0 for value in plot_series.values]
|
|
235
|
+
series.append((str(plot_series.name), values))
|
|
236
|
+
except Exception: # noqa: BLE001 - a broken chart cache must not kill the render
|
|
237
|
+
notes.append({"slide": slide_no, "shape": shape.name, "issue": "chart values unreadable"})
|
|
238
|
+
|
|
239
|
+
if not series or not categories:
|
|
240
|
+
_draw_text(page, plot, "chart data unavailable", 11, MUTED)
|
|
241
|
+
return
|
|
242
|
+
|
|
243
|
+
kind = str(getattr(chart, "chart_type", ""))
|
|
244
|
+
max_value = max([abs(value) for _, values in series for value in values] + [1.0])
|
|
245
|
+
baseline = plot.y1 - 16
|
|
246
|
+
|
|
247
|
+
if "PIE" in kind.upper() or "DOUGHNUT" in kind.upper():
|
|
248
|
+
values = series[0][1]
|
|
249
|
+
total = sum(abs(value) for value in values) or 1.0
|
|
250
|
+
center = fitz.Point((plot.x0 + plot.x1) / 2, (plot.y0 + baseline) / 2)
|
|
251
|
+
radius = min(plot.width, baseline - plot.y0) * 0.34
|
|
252
|
+
start = 0.0
|
|
253
|
+
shades = [0.25, 0.40, 0.55, 0.70, 0.85, 0.95]
|
|
254
|
+
for index, value in enumerate(values):
|
|
255
|
+
sweep = 360.0 * abs(value) / total
|
|
256
|
+
shade = shades[index % len(shades)]
|
|
257
|
+
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)
|
|
258
|
+
start += sweep
|
|
259
|
+
return
|
|
260
|
+
|
|
261
|
+
if "LINE" in kind.upper():
|
|
262
|
+
step = plot.width / max(len(categories) - 1, 1)
|
|
263
|
+
for series_index, (_, values) in enumerate(series):
|
|
264
|
+
points = []
|
|
265
|
+
for index, value in enumerate(values):
|
|
266
|
+
x = plot.x0 + step * index
|
|
267
|
+
y = baseline - (abs(value) / max_value) * (baseline - plot.y0 - 6)
|
|
268
|
+
points.append(fitz.Point(x, y))
|
|
269
|
+
if len(points) > 1:
|
|
270
|
+
shade = 0.20 + 0.18 * series_index
|
|
271
|
+
page.draw_polyline(points, color=(shade, shade, shade + 0.1), width=1.6)
|
|
272
|
+
for point in points:
|
|
273
|
+
page.draw_circle(point, 2.0, color=ACCENT, fill=ACCENT)
|
|
274
|
+
_draw_categories(page, plot, baseline, categories)
|
|
275
|
+
return
|
|
276
|
+
|
|
277
|
+
group_width = plot.width / max(len(categories), 1)
|
|
278
|
+
bar_width = group_width * 0.6 / max(len(series), 1)
|
|
279
|
+
for index, category in enumerate(categories):
|
|
280
|
+
for series_index, (_, values) in enumerate(series):
|
|
281
|
+
if index >= len(values):
|
|
282
|
+
continue
|
|
283
|
+
value = abs(values[index])
|
|
284
|
+
bar_height = (value / max_value) * (baseline - plot.y0 - 8)
|
|
285
|
+
x0 = plot.x0 + group_width * index + group_width * 0.2 + bar_width * series_index
|
|
286
|
+
bar = fitz.Rect(x0, baseline - bar_height, x0 + bar_width * 0.92, baseline)
|
|
287
|
+
shade = 0.30 + 0.22 * series_index
|
|
288
|
+
page.draw_rect(bar, color=ACCENT, fill=(shade * 0.55, shade * 0.75, shade), width=0.5)
|
|
289
|
+
_draw_text(page, fitz.Rect(bar.x0 - 6, bar.y0 - 12, bar.x1 + 6, bar.y0), _short(values[index]), 7.5, MUTED, 1)
|
|
290
|
+
page.draw_line(fitz.Point(plot.x0, baseline), fitz.Point(plot.x1, baseline), color=RULE, width=0.8)
|
|
291
|
+
_draw_categories(page, plot, baseline, categories)
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def _short(value: float) -> str:
|
|
295
|
+
return f"{value:g}"
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def _draw_categories(page, plot: fitz.Rect, baseline: float, categories: list[str]) -> None:
|
|
299
|
+
group_width = plot.width / max(max(len(categories), 1), 1)
|
|
300
|
+
for index, category in enumerate(categories):
|
|
301
|
+
cell = fitz.Rect(
|
|
302
|
+
plot.x0 + group_width * index,
|
|
303
|
+
baseline + 2,
|
|
304
|
+
plot.x0 + group_width * (index + 1) - 2,
|
|
305
|
+
baseline + 18,
|
|
306
|
+
)
|
|
307
|
+
_draw_text(page, cell, category[:18], 7.5, MUTED, 1)
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def render_preview(pptx_path: str | Path, out_dir: str | Path, width_px: int = 1600) -> dict[str, Any]:
|
|
311
|
+
"""Rasterise every slide to `<out_dir>/slide-NN.png`; returns a JSON-able report."""
|
|
312
|
+
presentation = open_presentation(pptx_path)
|
|
313
|
+
destination = Path(out_dir)
|
|
314
|
+
destination.mkdir(parents=True, exist_ok=True)
|
|
315
|
+
slide_w_in, slide_h_in = slide_size_in(presentation)
|
|
316
|
+
page_w = slide_w_in * 72
|
|
317
|
+
page_h = slide_h_in * 72
|
|
318
|
+
zoom = max(1.0, width_px / page_w)
|
|
319
|
+
|
|
320
|
+
files: list[str] = []
|
|
321
|
+
notes: list[dict[str, Any]] = []
|
|
322
|
+
document = fitz.open()
|
|
323
|
+
try:
|
|
324
|
+
for slide_no, slide in enumerate(presentation.slides, start=1):
|
|
325
|
+
page = document.new_page(width=page_w, height=page_h)
|
|
326
|
+
page.draw_rect(fitz.Rect(0, 0, page_w, page_h), color=None, fill=(1, 1, 1))
|
|
327
|
+
try:
|
|
328
|
+
background = slide.background.fill
|
|
329
|
+
rgb = background.fore_color.rgb
|
|
330
|
+
page.draw_rect(
|
|
331
|
+
fitz.Rect(0, 0, page_w, page_h),
|
|
332
|
+
color=None,
|
|
333
|
+
fill=(rgb[0] / 255, rgb[1] / 255, rgb[2] / 255),
|
|
334
|
+
)
|
|
335
|
+
except Exception: # noqa: BLE001 - most decks inherit a white master
|
|
336
|
+
pass
|
|
337
|
+
for shape in iter_shapes(slide.shapes):
|
|
338
|
+
_draw_shape(page, shape, slide_no, notes)
|
|
339
|
+
page.insert_text(
|
|
340
|
+
fitz.Point(page_w - 54, page_h - 10),
|
|
341
|
+
f"{slide_no}",
|
|
342
|
+
fontsize=7,
|
|
343
|
+
fontname="helv",
|
|
344
|
+
color=MUTED,
|
|
345
|
+
)
|
|
346
|
+
png = destination / f"slide-{slide_no:02d}.png"
|
|
347
|
+
page.get_pixmap(matrix=fitz.Matrix(zoom, zoom)).save(str(png))
|
|
348
|
+
files.append(str(png.resolve()))
|
|
349
|
+
finally:
|
|
350
|
+
document.close()
|
|
351
|
+
|
|
352
|
+
report = {
|
|
353
|
+
"ok": True,
|
|
354
|
+
"engine": "draft-preview",
|
|
355
|
+
"fidelity": "geometry-only",
|
|
356
|
+
"note": "Shapes redrawn from OOXML; fonts and theme colours are approximations. Use it to judge placement, collisions and overflow, not typography.",
|
|
357
|
+
"slides": len(files),
|
|
358
|
+
"slideSize": [round(slide_w_in, 3), round(slide_h_in, 3)],
|
|
359
|
+
"files": files,
|
|
360
|
+
"overflow": notes,
|
|
361
|
+
}
|
|
362
|
+
(destination / "preview.json").write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
363
|
+
return report
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def main(argv: list[str] | None = None) -> int:
|
|
367
|
+
import argparse
|
|
368
|
+
|
|
369
|
+
parser = argparse.ArgumentParser(description="Rasterise a .pptx to PNGs using a draft geometry renderer (no Office required).")
|
|
370
|
+
parser.add_argument("presentation", help="path to the .pptx")
|
|
371
|
+
parser.add_argument("--out-dir", required=True, help="directory for slide-NN.png and preview.json")
|
|
372
|
+
parser.add_argument("--width", type=int, default=1600, help="output pixel width (default 1600)")
|
|
373
|
+
args = parser.parse_args(argv)
|
|
374
|
+
|
|
375
|
+
report = render_preview(args.presentation, args.out_dir, args.width)
|
|
376
|
+
json.dump(report, sys.stdout, ensure_ascii=False, indent=2)
|
|
377
|
+
sys.stdout.write("\n")
|
|
378
|
+
return 0 if report["slides"] else 3
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
if __name__ == "__main__":
|
|
382
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,146 @@
|
|
|
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
|
+
# Windows consoles default to a legacy code page; every payload is UTF-8 JSON.
|
|
27
|
+
for _stream in (sys.stdout, sys.stderr):
|
|
28
|
+
try:
|
|
29
|
+
_stream.reconfigure(encoding="utf-8", errors="replace")
|
|
30
|
+
except (AttributeError, ValueError):
|
|
31
|
+
pass
|
|
32
|
+
|
|
33
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def office_cli() -> Path:
|
|
37
|
+
"""The plugin's helper CLI, which owns renderer discovery."""
|
|
38
|
+
return Path(__file__).resolve().parents[4] / "python" / "office_cli.py"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def rasterise_pdf(pdf_path: Path, out_dir: Path, dpi: int) -> list[str]:
|
|
42
|
+
try:
|
|
43
|
+
import pymupdf as fitz
|
|
44
|
+
except ImportError: # pragma: no cover
|
|
45
|
+
import fitz
|
|
46
|
+
|
|
47
|
+
files: list[str] = []
|
|
48
|
+
document = fitz.open(str(pdf_path))
|
|
49
|
+
try:
|
|
50
|
+
zoom = dpi / 72.0
|
|
51
|
+
for index, page in enumerate(document, start=1):
|
|
52
|
+
png = out_dir / f"slide-{index:02d}.png"
|
|
53
|
+
page.get_pixmap(matrix=fitz.Matrix(zoom, zoom)).save(str(png))
|
|
54
|
+
files.append(str(png))
|
|
55
|
+
finally:
|
|
56
|
+
document.close()
|
|
57
|
+
return files
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def render_with_office(source: Path, out_dir: Path, dpi: int) -> dict:
|
|
61
|
+
"""Export through the CLI, which tries every installed office suite."""
|
|
62
|
+
scratch = Path(tempfile.mkdtemp(prefix="dsh-render-"))
|
|
63
|
+
try:
|
|
64
|
+
completed = subprocess.run(
|
|
65
|
+
[sys.executable, str(office_cli()), "pptx", "render",
|
|
66
|
+
"--input", str(source), "--out-dir", str(scratch), "--dpi", str(dpi)],
|
|
67
|
+
capture_output=True, text=True, timeout=300, check=False,
|
|
68
|
+
)
|
|
69
|
+
if completed.returncode != 0:
|
|
70
|
+
raise RuntimeError((completed.stderr or completed.stdout or "render failed").strip()[:400])
|
|
71
|
+
report = json.loads(completed.stdout)
|
|
72
|
+
files: list[str] = []
|
|
73
|
+
for index, page in enumerate(report.get("pages") or [], start=1):
|
|
74
|
+
target = out_dir / f"slide-{index:02d}.png"
|
|
75
|
+
shutil.move(page, target)
|
|
76
|
+
files.append(str(target))
|
|
77
|
+
return {
|
|
78
|
+
"ok": bool(files),
|
|
79
|
+
"engine": report.get("renderer") or "office",
|
|
80
|
+
"fidelity": "faithful",
|
|
81
|
+
"slides": len(files),
|
|
82
|
+
"dpi": dpi,
|
|
83
|
+
"files": files,
|
|
84
|
+
}
|
|
85
|
+
finally:
|
|
86
|
+
shutil.rmtree(scratch, ignore_errors=True)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def render(input_path: str | Path, out_dir: str | Path, dpi: int = 110, engine: str = "auto", width: int = 1600) -> dict:
|
|
90
|
+
source = Path(input_path)
|
|
91
|
+
destination = Path(out_dir)
|
|
92
|
+
if not source.is_file():
|
|
93
|
+
return {"ok": False, "error": {"code": "input_missing", "message": f"no such file: {source}"}}
|
|
94
|
+
destination.mkdir(parents=True, exist_ok=True)
|
|
95
|
+
|
|
96
|
+
if source.suffix.lower() == ".pdf":
|
|
97
|
+
files = rasterise_pdf(source, destination, dpi)
|
|
98
|
+
return {"ok": bool(files), "engine": "pdf-raster", "fidelity": "faithful", "slides": len(files), "dpi": dpi, "files": files}
|
|
99
|
+
|
|
100
|
+
notes: list[str] = []
|
|
101
|
+
if engine in {"auto", "office"}:
|
|
102
|
+
try:
|
|
103
|
+
return render_with_office(source, destination, dpi)
|
|
104
|
+
except Exception as exc: # noqa: BLE001 - fall through to the draft engine
|
|
105
|
+
notes.append(f"office render failed: {exc}")
|
|
106
|
+
|
|
107
|
+
if engine == "office":
|
|
108
|
+
return {"ok": False, "engine": "office", "notes": notes, "error": {"code": "no_engine", "message": "no Microsoft Office, WPS Office, or LibreOffice renderer is available"}}
|
|
109
|
+
|
|
110
|
+
from preview_render import render_preview
|
|
111
|
+
|
|
112
|
+
report = render_preview(source, destination, width)
|
|
113
|
+
report["notes"] = notes + [
|
|
114
|
+
"Draft preview: geometry is exact, type is approximate. Treat colours and fonts as indicative only.",
|
|
115
|
+
]
|
|
116
|
+
report["files"] = [str(Path(item).resolve()) for item in report["files"]]
|
|
117
|
+
return report
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def main(argv: list[str] | None = None) -> int:
|
|
121
|
+
import argparse
|
|
122
|
+
|
|
123
|
+
parser = argparse.ArgumentParser(description="Render a .pptx or .pdf into one PNG per page.")
|
|
124
|
+
parser.add_argument("--input", required=True, help="deck or PDF to render")
|
|
125
|
+
parser.add_argument("--out-dir", required=True, help="directory for slide-NN.png and render.json")
|
|
126
|
+
parser.add_argument("--dpi", type=int, default=110, help="raster dpi when a real renderer is used (default 110)")
|
|
127
|
+
parser.add_argument("--width", type=int, default=1600, help="pixel width for the draft renderer (default 1600)")
|
|
128
|
+
parser.add_argument("--engine", choices=("auto", "office", "draft"), default="auto", help="force an engine (default auto)")
|
|
129
|
+
parser.add_argument("--json-out", help="also write the report here")
|
|
130
|
+
args = parser.parse_args(argv)
|
|
131
|
+
|
|
132
|
+
report = render(args.input, args.out_dir, args.dpi, args.engine, args.width)
|
|
133
|
+
out_dir = Path(args.out_dir)
|
|
134
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
135
|
+
(out_dir / "render.json").write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
136
|
+
if args.json_out:
|
|
137
|
+
Path(args.json_out).write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
138
|
+
json.dump(report, sys.stdout, ensure_ascii=False, indent=2)
|
|
139
|
+
sys.stdout.write("\n")
|
|
140
|
+
if report.get("ok"):
|
|
141
|
+
return 0
|
|
142
|
+
return 2 if report.get("error", {}).get("code") == "input_missing" else 3
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
if __name__ == "__main__":
|
|
146
|
+
raise SystemExit(main())
|