skilldrop-cli 0.16.34 → 0.16.36

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.
Files changed (212) hide show
  1. package/README.md +1 -0
  2. package/agents/README.md +4 -1
  3. package/agents/docs-reviewer.md +47 -0
  4. package/agents/finding-adjudicator.md +49 -0
  5. package/agents/test-reviewer.md +52 -0
  6. package/catalogue.json +17 -7
  7. package/model-routing.json +40 -0
  8. package/package.json +1 -1
  9. package/packs/ai-engineering/skills/agent-config-review/SKILL.md +116 -0
  10. package/packs/ai-engineering/skills/agent-config-review/evals/eval_queries.json +12 -0
  11. package/packs/ai-engineering/skills/agent-config-review/evals/evals.json +75 -0
  12. package/packs/ai-engineering/skills/agent-config-review/evals/files/1/.claude/agents/db-migrator.md +8 -0
  13. package/packs/ai-engineering/skills/agent-config-review/evals/files/1/.claude/agents/reviewer.md +9 -0
  14. package/packs/ai-engineering/skills/agent-config-review/evals/files/1/.claude/settings.json +48 -0
  15. package/packs/ai-engineering/skills/agent-config-review/evals/files/1/.claude/settings.local.json +5 -0
  16. package/packs/ai-engineering/skills/agent-config-review/evals/files/1/.mcp.json +22 -0
  17. package/packs/ai-engineering/skills/agent-config-review/evals/files/1/package.json +12 -0
  18. package/packs/ai-engineering/skills/agent-config-review/evals/files/2/.cursor/mcp.json +17 -0
  19. package/packs/ai-engineering/skills/agent-config-review/evals/files/2/config.toml +21 -0
  20. package/packs/ai-engineering/skills/agent-config-review/evals/files/4/my-user-settings.json +11 -0
  21. package/packs/ai-engineering/skills/agent-config-review/evals/files/4/quillstack/.claude/agents/release-notes.md +13 -0
  22. package/packs/ai-engineering/skills/agent-config-review/evals/files/4/quillstack/.claude/settings.json +39 -0
  23. package/packs/ai-engineering/skills/agent-config-review/evals/files/4/quillstack/.mcp.json +18 -0
  24. package/packs/ai-engineering/skills/agent-config-review/evals/files/4/quillstack/package.json +14 -0
  25. package/packs/ai-engineering/skills/agent-config-review/evals/files/5/.github/workflows/codex-triage.yml +29 -0
  26. package/packs/ai-engineering/skills/agent-config-review/evals/files/5/AGENTS.md +16 -0
  27. package/packs/ai-engineering/skills/agent-config-review/evals/files/5/codex/config.toml +34 -0
  28. package/packs/ai-engineering/skills/agent-config-review/examples/inputs/tidewater-ingest/.claude/settings.json +22 -0
  29. package/packs/ai-engineering/skills/agent-config-review/examples/inputs/tidewater-ingest/.mcp.json +9 -0
  30. package/packs/ai-engineering/skills/agent-config-review/examples/python-repo-tighten.md +81 -0
  31. package/packs/ai-engineering/skills/agent-config-review/manifest.json +60 -0
  32. package/packs/ai-engineering/skills/agent-config-review/reference.md +205 -0
  33. package/packs/ai-engineering/skills/agent-config-review/scripts/agent_config_scan.py +989 -0
  34. package/packs/ai-engineering/skills/agent-incident-response/SKILL.md +175 -0
  35. package/packs/ai-engineering/skills/agent-incident-response/evals/eval_queries.json +14 -0
  36. package/packs/ai-engineering/skills/agent-incident-response/evals/evals.json +107 -0
  37. package/packs/ai-engineering/skills/agent-incident-response/evals/files/1/agent-session-excerpt.txt +11 -0
  38. package/packs/ai-engineering/skills/agent-incident-response/evals/files/1/ci-build-log.txt +24 -0
  39. package/packs/ai-engineering/skills/agent-incident-response/evals/files/1/incident-notes.md +19 -0
  40. package/packs/ai-engineering/skills/agent-incident-response/evals/files/2/mcp-config.json +20 -0
  41. package/packs/ai-engineering/skills/agent-incident-response/evals/files/2/session-transcript.txt +8 -0
  42. package/packs/ai-engineering/skills/agent-incident-response/evals/files/4/agent-session.log +70 -0
  43. package/packs/ai-engineering/skills/agent-incident-response/evals/files/4/infra/staging-db.tf +25 -0
  44. package/packs/ai-engineering/skills/agent-incident-response/evals/files/5/jobs/nightly-triage/job.yaml +19 -0
  45. package/packs/ai-engineering/skills/agent-incident-response/evals/files/5/jobs/nightly-triage/run.log +13 -0
  46. package/packs/ai-engineering/skills/agent-incident-response/evals/files/5/usage-export-2026-10-20.csv +13 -0
  47. package/packs/ai-engineering/skills/agent-incident-response/evals/files/6/.mcp.json +20 -0
  48. package/packs/ai-engineering/skills/agent-incident-response/evals/files/6/git-log.txt +12 -0
  49. package/packs/ai-engineering/skills/agent-incident-response/evals/files/6/notesync-excerpt.txt +30 -0
  50. package/packs/ai-engineering/skills/agent-incident-response/evals/files/6/notesync.log +15 -0
  51. package/packs/ai-engineering/skills/agent-incident-response/evals/files/6/ssh-config.txt +16 -0
  52. package/packs/ai-engineering/skills/agent-incident-response/evals/files/7/.github/workflows/ci.yml +28 -0
  53. package/packs/ai-engineering/skills/agent-incident-response/evals/files/7/SECURITY.md +8 -0
  54. package/packs/ai-engineering/skills/agent-incident-response/evals/files/7/issue-479.md +35 -0
  55. package/packs/ai-engineering/skills/agent-incident-response/evals/files/7/package.json +15 -0
  56. package/packs/ai-engineering/skills/agent-incident-response/evals/files/7/pr-482.diff +20 -0
  57. package/packs/ai-engineering/skills/agent-incident-response/evals/files/7/run-log.txt +30 -0
  58. package/packs/ai-engineering/skills/agent-incident-response/examples/injected-issue-comment.md +57 -0
  59. package/packs/ai-engineering/skills/agent-incident-response/manifest.json +55 -0
  60. package/packs/ai-engineering/skills/agent-incident-response/reference.md +152 -0
  61. package/packs/ai-engineering/skills/agent-incident-response/scripts/find_secret_exposure.py +239 -0
  62. package/packs/ai-engineering/skills/agent-incident-response/scripts/usage_split.py +194 -0
  63. package/packs/converters/skills/file-to-markdown/SKILL.md +3 -2
  64. package/packs/converters/skills/file-to-markdown/evals/eval_queries.json +4 -0
  65. package/packs/converters/skills/file-to-markdown/manifest.json +2 -2
  66. package/packs/converters/skills/file-to-markdown/scripts/to_markdown.py +340 -7
  67. package/packs/converters/skills/md-to-confluence/SKILL.md +139 -0
  68. package/packs/converters/skills/md-to-confluence/evals/eval_queries.json +42 -0
  69. package/packs/converters/skills/md-to-confluence/evals/evals.json +89 -0
  70. package/packs/converters/skills/md-to-confluence/evals/files/1/img/latency-dashboard.png +0 -0
  71. package/packs/converters/skills/md-to-confluence/evals/files/1/payments-runbook.md +60 -0
  72. package/packs/converters/skills/md-to-confluence/evals/files/2/deploy-checklist.md +33 -0
  73. package/packs/converters/skills/md-to-confluence/evals/files/3/img/after/screen.png +0 -0
  74. package/packs/converters/skills/md-to-confluence/evals/files/3/img/before/screen.png +0 -0
  75. package/packs/converters/skills/md-to-confluence/evals/files/3/release-notes-4.2.md +49 -0
  76. package/packs/converters/skills/md-to-confluence/evals/files/4/docs/img/topology.png +0 -0
  77. package/packs/converters/skills/md-to-confluence/evals/files/4/docs/ingest-pipeline-design.md +65 -0
  78. package/packs/converters/skills/md-to-confluence/evals/files/5/support/troubleshooting-sso.md +57 -0
  79. package/packs/converters/skills/md-to-confluence/examples/inputs/img/queue-depth.png +0 -0
  80. package/packs/converters/skills/md-to-confluence/examples/inputs/queue-runbook.fixed.md +21 -0
  81. package/packs/converters/skills/md-to-confluence/examples/inputs/queue-runbook.md +21 -0
  82. package/packs/converters/skills/md-to-confluence/examples/queue-runbook.md +57 -0
  83. package/packs/converters/skills/md-to-confluence/manifest.json +52 -0
  84. package/packs/converters/skills/md-to-confluence/reference.md +92 -0
  85. package/packs/converters/skills/md-to-confluence/scripts/md_to_confluence.py +821 -0
  86. package/packs/converters/skills/md-to-pptx/SKILL.md +131 -0
  87. package/packs/converters/skills/md-to-pptx/evals/eval_queries.json +12 -0
  88. package/packs/converters/skills/md-to-pptx/evals/evals.json +84 -0
  89. package/packs/converters/skills/md-to-pptx/evals/files/1/img/defect-burndown.png +0 -0
  90. package/packs/converters/skills/md-to-pptx/evals/files/1/status-report.md +75 -0
  91. package/packs/converters/skills/md-to-pptx/evals/files/3/brand.json +12 -0
  92. package/packs/converters/skills/md-to-pptx/evals/files/3/night-shift-induction.md +53 -0
  93. package/packs/converters/skills/md-to-pptx/evals/files/4/charts/ontime.png +0 -0
  94. package/packs/converters/skills/md-to-pptx/evals/files/4/qbr-q3.md +85 -0
  95. package/packs/converters/skills/md-to-pptx/evals/files/5/vendor-sync-notes.md +28 -0
  96. package/packs/converters/skills/md-to-pptx/examples/support-weekly-input.md +48 -0
  97. package/packs/converters/skills/md-to-pptx/examples/support-weekly.md +75 -0
  98. package/packs/converters/skills/md-to-pptx/examples/tickets-by-week.png +0 -0
  99. package/packs/converters/skills/md-to-pptx/manifest.json +49 -0
  100. package/packs/converters/skills/md-to-pptx/reference.md +139 -0
  101. package/packs/converters/skills/md-to-pptx/scripts/md_to_pptx.py +1841 -0
  102. package/packs/dev-team/loops/modernize/LOOP.md +124 -0
  103. package/packs/dev-team/loops/modernize/evals/eval_queries.json +9 -0
  104. package/packs/dev-team/loops/modernize/evals/evals.json +27 -0
  105. package/packs/dev-team/loops/modernize/loop.json +60 -0
  106. package/packs/dev-team/skills/behaviour-equivalence/SKILL.md +155 -0
  107. package/packs/dev-team/skills/behaviour-equivalence/evals/eval_queries.json +11 -0
  108. package/packs/dev-team/skills/behaviour-equivalence/evals/evals.json +76 -0
  109. package/packs/dev-team/skills/behaviour-equivalence/evals/files/1/cases.jsonl +12 -0
  110. package/packs/dev-team/skills/behaviour-equivalence/evals/files/1/new.jsonl +12 -0
  111. package/packs/dev-team/skills/behaviour-equivalence/evals/files/1/notes.md +15 -0
  112. package/packs/dev-team/skills/behaviour-equivalence/evals/files/1/old.jsonl +12 -0
  113. package/packs/dev-team/skills/behaviour-equivalence/evals/files/1/old_rerun.jsonl +12 -0
  114. package/packs/dev-team/skills/behaviour-equivalence/evals/files/1/pricing_v1.py +45 -0
  115. package/packs/dev-team/skills/behaviour-equivalence/evals/files/1/pricing_v2.py +65 -0
  116. package/packs/dev-team/skills/behaviour-equivalence/evals/files/1/rules.json +10 -0
  117. package/packs/dev-team/skills/behaviour-equivalence/evals/files/3/diff-summary.txt +16 -0
  118. package/packs/dev-team/skills/behaviour-equivalence/evals/files/4/diff.txt +13 -0
  119. package/packs/dev-team/skills/behaviour-equivalence/evals/files/4/fee_calc_py2.py +16 -0
  120. package/packs/dev-team/skills/behaviour-equivalence/evals/files/4/fee_calc_py3.py +24 -0
  121. package/packs/dev-team/skills/behaviour-equivalence/evals/files/4/input.csv +13 -0
  122. package/packs/dev-team/skills/behaviour-equivalence/evals/files/4/new_output.csv +13 -0
  123. package/packs/dev-team/skills/behaviour-equivalence/evals/files/4/old_output.csv +13 -0
  124. package/packs/dev-team/skills/behaviour-equivalence/evals/files/5/TEAM_NOTES.md +19 -0
  125. package/packs/dev-team/skills/behaviour-equivalence/evals/files/5/normalisation.yaml +12 -0
  126. package/packs/dev-team/skills/behaviour-equivalence/evals/files/5/recordings/new_run.jsonl +8 -0
  127. package/packs/dev-team/skills/behaviour-equivalence/evals/files/5/recordings/old_run1.jsonl +8 -0
  128. package/packs/dev-team/skills/behaviour-equivalence/evals/files/5/recordings/old_run2.jsonl +8 -0
  129. package/packs/dev-team/skills/behaviour-equivalence/examples/delivery-date.md +78 -0
  130. package/packs/dev-team/skills/behaviour-equivalence/manifest.json +33 -0
  131. package/packs/dev-team/skills/behaviour-equivalence/reference.md +97 -0
  132. package/packs/dev-team/skills/behaviour-equivalence/scripts/golden_diff.py +524 -0
  133. package/packs/dev-team/skills/behaviour-equivalence/templates/accepted.json +10 -0
  134. package/packs/dev-team/skills/behaviour-equivalence/templates/rules.json +7 -0
  135. package/packs/frontend/pack.json +55 -0
  136. package/packs/frontend/skills/rendering-strategy/SKILL.md +149 -0
  137. package/packs/frontend/skills/rendering-strategy/evals/eval_queries.json +38 -0
  138. package/packs/frontend/skills/rendering-strategy/evals/evals.json +77 -0
  139. package/packs/frontend/skills/rendering-strategy/evals/files/1/fernhill-app.md +40 -0
  140. package/packs/frontend/skills/rendering-strategy/evals/files/3/routes.md +33 -0
  141. package/packs/frontend/skills/rendering-strategy/evals/files/4/build-notes.txt +5 -0
  142. package/packs/frontend/skills/rendering-strategy/evals/files/4/nuxt.config.ts +19 -0
  143. package/packs/frontend/skills/rendering-strategy/evals/files/4/pages/recipes/[slug].vue +37 -0
  144. package/packs/frontend/skills/rendering-strategy/evals/files/4/routes.md +15 -0
  145. package/packs/frontend/skills/rendering-strategy/evals/files/5/routes.txt +18 -0
  146. package/packs/frontend/skills/rendering-strategy/evals/files/5/src/hooks.server.ts +14 -0
  147. package/packs/frontend/skills/rendering-strategy/evals/files/5/src/routes/(marketing)/pricing/+page.server.ts +10 -0
  148. package/packs/frontend/skills/rendering-strategy/evals/files/5/src/routes/+layout.server.ts +10 -0
  149. package/packs/frontend/skills/rendering-strategy/evals/files/5/svelte.config.js +10 -0
  150. package/packs/frontend/skills/rendering-strategy/evals/files/5/traffic.md +18 -0
  151. package/packs/frontend/skills/rendering-strategy/examples/saas-site.md +42 -0
  152. package/packs/frontend/skills/rendering-strategy/manifest.json +50 -0
  153. package/packs/frontend/skills/rendering-strategy/reference.md +116 -0
  154. package/packs/frontend/skills/rendering-strategy/scripts/render_load.py +181 -0
  155. package/packs/frontend/skills/web-performance-review/SKILL.md +106 -0
  156. package/packs/frontend/skills/web-performance-review/evals/eval_queries.json +38 -0
  157. package/packs/frontend/skills/web-performance-review/evals/evals.json +76 -0
  158. package/packs/frontend/skills/web-performance-review/evals/files/1/psi.json +1065 -0
  159. package/packs/frontend/skills/web-performance-review/evals/files/2/after.json +359 -0
  160. package/packs/frontend/skills/web-performance-review/evals/files/2/before.json +375 -0
  161. package/packs/frontend/skills/web-performance-review/evals/files/4/psi-pdp-mobile-2026-10-19.json +171 -0
  162. package/packs/frontend/skills/web-performance-review/evals/files/4/rum-inp-attribution-oct14-19.csv +6 -0
  163. package/packs/frontend/skills/web-performance-review/evals/files/4/rum-inp-daily.csv +15 -0
  164. package/packs/frontend/skills/web-performance-review/evals/files/5/crux-home-phone.json +35 -0
  165. package/packs/frontend/skills/web-performance-review/evals/files/5/lighthouse-home-mobile.json +173 -0
  166. package/packs/frontend/skills/web-performance-review/examples/quayside-article-lhr.json +480 -0
  167. package/packs/frontend/skills/web-performance-review/examples/quayside-article.md +103 -0
  168. package/packs/frontend/skills/web-performance-review/manifest.json +48 -0
  169. package/packs/frontend/skills/web-performance-review/reference.md +105 -0
  170. package/packs/frontend/skills/web-performance-review/scripts/lh_summary.py +743 -0
  171. package/packs/product-manager/skills/lean-canvas/SKILL.md +162 -0
  172. package/packs/product-manager/skills/lean-canvas/evals/eval_queries.json +42 -0
  173. package/packs/product-manager/skills/lean-canvas/evals/evals.json +82 -0
  174. package/packs/product-manager/skills/lean-canvas/evals/files/1/pawpass-notes.md +54 -0
  175. package/packs/product-manager/skills/lean-canvas/evals/files/3/lunchloop-pitch.md +30 -0
  176. package/packs/product-manager/skills/lean-canvas/evals/files/4/clubkit-notes.md +44 -0
  177. package/packs/product-manager/skills/lean-canvas/evals/files/5/cost-model.csv +5 -0
  178. package/packs/product-manager/skills/lean-canvas/evals/files/5/proposal.md +40 -0
  179. package/packs/product-manager/skills/lean-canvas/evals/files/5/stakeholder-emails.txt +18 -0
  180. package/packs/product-manager/skills/lean-canvas/examples/bike-repair.md +72 -0
  181. package/packs/product-manager/skills/lean-canvas/manifest.json +34 -0
  182. package/packs/product-manager/skills/lean-canvas/reference.md +102 -0
  183. package/packs/product-manager/skills/lean-canvas/scripts/unit_economics.py +211 -0
  184. package/packs/solution-architect/skills/api-contract-draft/SKILL.md +2 -2
  185. package/packs/solution-architect/skills/api-contract-draft/manifest.json +28 -6
  186. package/packs/solution-architect/skills/event-contract-draft/SKILL.md +167 -0
  187. package/packs/solution-architect/skills/event-contract-draft/evals/eval_queries.json +38 -0
  188. package/packs/solution-architect/skills/event-contract-draft/evals/evals.json +76 -0
  189. package/packs/solution-architect/skills/event-contract-draft/evals/files/1/order-events-brief.md +45 -0
  190. package/packs/solution-architect/skills/event-contract-draft/evals/files/4/NOTES.md +23 -0
  191. package/packs/solution-architect/skills/event-contract-draft/evals/files/4/schemas/shipment-update.json +40 -0
  192. package/packs/solution-architect/skills/event-contract-draft/evals/files/5/invoice.example.json +25 -0
  193. package/packs/solution-architect/skills/event-contract-draft/examples/order-service.asyncapi.json +246 -0
  194. package/packs/solution-architect/skills/event-contract-draft/examples/order-service.md +92 -0
  195. package/packs/solution-architect/skills/event-contract-draft/manifest.json +25 -0
  196. package/packs/solution-architect/skills/event-contract-draft/reference.md +113 -0
  197. package/packs/solution-architect/skills/event-contract-draft/scripts/check_asyncapi.py +502 -0
  198. package/packs/solution-architect/skills/event-contract-draft/templates/asyncapi-skeleton.json +144 -0
  199. package/packs/sre-oncall/skills/slo-spec/SKILL.md +161 -0
  200. package/packs/sre-oncall/skills/slo-spec/evals/eval_queries.json +38 -0
  201. package/packs/sre-oncall/skills/slo-spec/evals/evals.json +75 -0
  202. package/packs/sre-oncall/skills/slo-spec/evals/files/1/order-service.md +51 -0
  203. package/packs/sre-oncall/skills/slo-spec/evals/files/3/search-slo-status.md +30 -0
  204. package/packs/sre-oncall/skills/slo-spec/evals/files/4/alerts/payments-api.rules.yaml +16 -0
  205. package/packs/sre-oncall/skills/slo-spec/evals/files/4/docs/dependencies.md +8 -0
  206. package/packs/sre-oncall/skills/slo-spec/evals/files/4/metrics/summary-90d.md +51 -0
  207. package/packs/sre-oncall/skills/slo-spec/evals/files/5/ops-notes.md +34 -0
  208. package/packs/sre-oncall/skills/slo-spec/examples/ticket-booking.md +88 -0
  209. package/packs/sre-oncall/skills/slo-spec/manifest.json +34 -0
  210. package/packs/sre-oncall/skills/slo-spec/reference.md +113 -0
  211. package/packs/sre-oncall/skills/slo-spec/scripts/slo_math.py +449 -0
  212. package/packs/sre-oncall/skills/slo-spec/templates/slo-spec.md +35 -0
@@ -0,0 +1,1841 @@
1
+ #!/usr/bin/env python3
2
+ """Convert a Markdown document to a PowerPoint deck (.pptx). Stdlib only: zip + XML by hand.
3
+
4
+ Mapping (one rule each, so the deck is predictable):
5
+ title slide --title, else the first # heading, else front matter title:; subtitle from front
6
+ matter subtitle/author/presenter/date, or one short paragraph right under the #
7
+ ## heading starts a slide; its text is the slide title. A later # heading starts a section
8
+ slide (or a content slide, when text follows it before the next ##)
9
+ ### to ###### a bold subheading line on the current slide
10
+ lists bulleted / numbered paragraphs, nesting kept to three levels (deeper is flattened)
11
+ paragraphs body text; > quotes in italic
12
+ pipe tables real PowerPoint tables (a:tbl) with a shaded header row; long tables split by
13
+ rows onto continued slides, the header row repeated
14
+ images local PNG / JPEG embedded, scaled down to fit (never up)
15
+ code blocks a shaded monospace text box
16
+ --- forces a new slide, titled "<title> (continued)"
17
+ <!-- notes: ... --> speaker notes for the slide it sits on (the only notes convention)
18
+
19
+ Overflow: text height is estimated per slide; a slide that would overflow is split into
20
+ "<title> (continued)" slides instead of shrinking text. Every split is reported.
21
+
22
+ Dropped or changed, each with a WARN line naming the source line: raw HTML blocks and other
23
+ HTML comments, inline HTML tags (text kept), footnotes, remote / missing / non-PNG-JPEG images,
24
+ nesting past three levels, code or tables inside list items, front matter (title fields used),
25
+ links to local files, a "Notes:" paragraph (kept on the slide, not made into notes).
26
+
27
+ After writing, the file is re-opened and checked: every XML part parses, every internal
28
+ relationship target exists, and every part has a content type.
29
+
30
+ Usage:
31
+ python3 md_to_pptx.py input.md -o deck.pptx [--brand brand.json] [--title "..."]
32
+ [--aspect 16:9|4:3]
33
+ (input "-" reads Markdown from stdin; image paths are then relative to the current folder)
34
+ """
35
+ import argparse
36
+ import datetime
37
+ import json
38
+ import os
39
+ import posixpath
40
+ import re
41
+ import struct
42
+ import sys
43
+ import zipfile
44
+ from urllib.parse import quote
45
+ from xml.dom import minidom
46
+ from xml.sax.saxutils import escape as _esc
47
+
48
+ WARNINGS = []
49
+ QUIET = [False] # set while measuring, so a cell parsed twice warns once
50
+
51
+
52
+ def warn(line, msg):
53
+ if QUIET[0]:
54
+ return
55
+ where = "line %d: " % line if line else ""
56
+ if where + msg in WARNINGS:
57
+ return
58
+ WARNINGS.append(where + msg)
59
+ print("WARN %s%s" % (where, msg), file=sys.stderr)
60
+
61
+
62
+ def die(msg, code=1):
63
+ print("ERROR %s" % msg, file=sys.stderr)
64
+ sys.exit(code)
65
+
66
+
67
+ _BAD_XML = dict.fromkeys(c for c in range(0x20) if c not in (0x09, 0x0A, 0x0D))
68
+
69
+
70
+ def x(s):
71
+ """Escape text for XML element content and attributes."""
72
+ return _esc(s.translate(_BAD_XML), {'"': "&quot;"})
73
+
74
+
75
+ # =========================================================================== block parsing
76
+
77
+ RE_ATX = re.compile(r"^ {0,3}(#{1,6})(?:[ \t]+(.*?))?(?:[ \t]+#+)?[ \t]*$")
78
+ RE_SETEXT = re.compile(r"^ {0,3}(=+|-+)[ \t]*$")
79
+ RE_HR = re.compile(r"^ {0,3}((\*[ \t]*){3,}|(-[ \t]*){3,}|(_[ \t]*){3,})$")
80
+ RE_FENCE = re.compile(r"^( {0,3})(`{3,}|~{3,})[ \t]*([^`]*)$")
81
+ RE_ITEM = re.compile(r"^([ \t]*)([-*+]|\d{1,9}[.)])(?:[ \t]+(.*)|$)")
82
+ RE_TABLE_SEP = re.compile(r"^[ \t]*\|?[ \t]*:?-+:?[ \t]*(\|[ \t]*:?-+:?[ \t]*)*\|?[ \t]*$")
83
+ RE_HTML_BLOCK = re.compile(r"^ {0,3}<(/?[A-Za-z][A-Za-z0-9-]*(\s[^>]*)?/?>|/?[A-Za-z][A-Za-z0-9-]*\s*$)")
84
+ RE_FOOTDEF = re.compile(r"^ {0,3}\[\^[^\]]+\]:")
85
+ RE_REFDEF = re.compile(r"^ {0,3}\[([^\]^][^\]]*)\]:[ \t]*<?(\S+?)>?(?:[ \t]+[\"'(].*[\"')])?[ \t]*$")
86
+ RE_NOTES = re.compile(r"^\s*(?:speaker[ _-]?)?notes?\s*:\s*(.*)$", re.I | re.S)
87
+ RE_NOTES_PARA = re.compile(r"^\s*(?:\*\*|__)?\s*(?:speaker\s+)?notes\s*(?:\*\*|__)?\s*:", re.I)
88
+
89
+
90
+ def expand_tabs(line):
91
+ return line.expandtabs(4)
92
+
93
+
94
+ def indent_of(line):
95
+ return len(line) - len(line.lstrip(" "))
96
+
97
+
98
+ def split_row(line):
99
+ s = line.strip()
100
+ if s.startswith("|"):
101
+ s = s[1:]
102
+ if s.endswith("|") and not s.endswith("\\|"):
103
+ s = s[:-1]
104
+ cells, cur, i = [], "", 0
105
+ in_code = False
106
+ while i < len(s):
107
+ c = s[i]
108
+ if c == "\\" and i + 1 < len(s) and s[i + 1] == "|":
109
+ cur += "|"
110
+ i += 2
111
+ continue
112
+ if c == "`":
113
+ in_code = not in_code
114
+ if c == "|" and not in_code:
115
+ cells.append(cur.strip())
116
+ cur = ""
117
+ else:
118
+ cur += c
119
+ i += 1
120
+ cells.append(cur.strip())
121
+ return cells
122
+
123
+
124
+ def parse_blocks(lines, base_line, refs):
125
+ """lines: list of str (no newline). Returns a list of block dicts, each with 'line'."""
126
+ blocks = []
127
+ i = 0
128
+ n = len(lines)
129
+ para = []
130
+
131
+ def flush_para():
132
+ if para:
133
+ blocks.append({"type": "para", "lines": list(para), "line": para[0][0]})
134
+ del para[:]
135
+
136
+ while i < n:
137
+ line = expand_tabs(lines[i])
138
+ ln = base_line + i
139
+ stripped = line.strip()
140
+
141
+ if not stripped:
142
+ flush_para()
143
+ i += 1
144
+ continue
145
+
146
+ m = RE_FENCE.match(line)
147
+ if m:
148
+ flush_para()
149
+ fence, info = m.group(2), m.group(3).strip()
150
+ ind = len(m.group(1))
151
+ body = []
152
+ i += 1
153
+ closed = False
154
+ while i < n:
155
+ l2 = expand_tabs(lines[i])
156
+ if l2.strip().startswith(fence[0] * len(fence)) and set(l2.strip()) <= {fence[0]}:
157
+ closed = True
158
+ i += 1
159
+ break
160
+ body.append(l2[ind:] if indent_of(l2) >= ind else l2.lstrip(" "))
161
+ i += 1
162
+ if not closed:
163
+ warn(ln, "code fence never closed; ran to end of file")
164
+ lang = info.split()[0] if info else ""
165
+ if lang.lower() == "mermaid":
166
+ warn(ln, "mermaid block kept as code text, not rendered as a diagram")
167
+ blocks.append({"type": "code", "text": "\n".join(body), "line": ln})
168
+ continue
169
+
170
+ if not para and indent_of(line) >= 4:
171
+ body = []
172
+ while i < n and (not lines[i].strip() or indent_of(expand_tabs(lines[i])) >= 4):
173
+ body.append(expand_tabs(lines[i])[4:])
174
+ i += 1
175
+ while body and not body[-1].strip():
176
+ body.pop()
177
+ blocks.append({"type": "code", "text": "\n".join(body), "line": ln})
178
+ continue
179
+
180
+ if stripped.startswith("<!--"):
181
+ flush_para()
182
+ buf = [stripped]
183
+ while "-->" not in buf[-1] and i + 1 < n:
184
+ i += 1
185
+ buf.append(lines[i])
186
+ i += 1
187
+ body = "\n".join(buf)
188
+ if "-->" not in body:
189
+ warn(ln, "HTML comment never closed; ran to end of file")
190
+ tail = body.split("-->", 1)[1].strip() if "-->" in body else ""
191
+ inner = body[4:].split("-->", 1)[0]
192
+ nm = RE_NOTES.match(inner)
193
+ if nm:
194
+ text = "\n".join(l.strip() for l in nm.group(1).strip().split("\n"))
195
+ blocks.append({"type": "notes", "text": text, "line": ln})
196
+ else:
197
+ warn(ln, "HTML comment dropped (only <!-- notes: ... --> becomes speaker notes)")
198
+ if tail:
199
+ warn(ln, "text after the closing --> on the same line dropped")
200
+ continue
201
+
202
+ m = RE_ATX.match(line)
203
+ if m:
204
+ flush_para()
205
+ level = len(m.group(1))
206
+ text = (m.group(2) or "").strip()
207
+ blocks.append({"type": "heading", "level": level, "text": text, "line": ln})
208
+ i += 1
209
+ continue
210
+
211
+ if para and RE_SETEXT.match(line) and len(para) == 1:
212
+ level = 1 if stripped.startswith("=") else 2
213
+ blocks.append({"type": "heading", "level": level, "text": para[0][1].strip(), "line": para[0][0]})
214
+ del para[:]
215
+ i += 1
216
+ continue
217
+
218
+ if RE_HR.match(line):
219
+ flush_para()
220
+ blocks.append({"type": "hr", "line": ln})
221
+ i += 1
222
+ continue
223
+
224
+ if RE_FOOTDEF.match(line):
225
+ flush_para()
226
+ warn(ln, "footnote definition dropped (footnotes are not supported)")
227
+ i += 1
228
+ while i < n and lines[i].strip() and indent_of(expand_tabs(lines[i])) >= 2:
229
+ i += 1
230
+ continue
231
+
232
+ m = RE_REFDEF.match(line)
233
+ if m and not para:
234
+ refs[m.group(1).strip().lower()] = m.group(2)
235
+ i += 1
236
+ continue
237
+
238
+ if not para and RE_HTML_BLOCK.match(line):
239
+ start = ln
240
+ while i < n and lines[i].strip():
241
+ i += 1
242
+ end = base_line + i - 1
243
+ warn(start, "raw HTML block dropped" + (" (lines %d-%d)" % (start, end) if end > start else ""))
244
+ continue
245
+
246
+ if stripped.startswith(">"):
247
+ flush_para()
248
+ inner = []
249
+ start = ln
250
+ while i < n:
251
+ l2 = expand_tabs(lines[i])
252
+ s2 = l2.lstrip(" ")
253
+ if s2.startswith(">"):
254
+ s2 = s2[1:]
255
+ if s2.startswith(" "):
256
+ s2 = s2[1:]
257
+ inner.append(s2)
258
+ elif l2.strip() and inner and inner[-1].strip():
259
+ inner.append(l2.strip())
260
+ else:
261
+ break
262
+ i += 1
263
+ blocks.append({"type": "quote", "blocks": parse_blocks(inner, start, refs), "line": start})
264
+ continue
265
+
266
+ if "|" in line and i + 1 < n and RE_TABLE_SEP.match(lines[i + 1]) and "-" in lines[i + 1]:
267
+ flush_para()
268
+ header = split_row(line)
269
+ aligns = []
270
+ for c in split_row(lines[i + 1]):
271
+ c = c.strip()
272
+ if c.startswith(":") and c.endswith(":"):
273
+ aligns.append("ctr")
274
+ elif c.endswith(":"):
275
+ aligns.append("r")
276
+ else:
277
+ aligns.append("l")
278
+ rows = []
279
+ i += 2
280
+ while i < n and lines[i].strip() and "|" in lines[i]:
281
+ rows.append(split_row(lines[i]))
282
+ i += 1
283
+ width = len(header)
284
+ for r_i, r in enumerate(rows):
285
+ if len(r) != width:
286
+ warn(ln, "table row %d has %d cells, header has %d; padded or cut" % (r_i + 1, len(r), width))
287
+ rows = [(r + [""] * width)[:width] for r in rows]
288
+ aligns = (aligns + ["l"] * width)[:width]
289
+ blocks.append({"type": "table", "header": header, "rows": rows, "aligns": aligns, "line": ln})
290
+ continue
291
+
292
+ m = RE_ITEM.match(line)
293
+ if m and (not para or m.group(3)):
294
+ marker = m.group(2)
295
+ if not para or not marker[0].isdigit() or int(marker[:-1]) == 1:
296
+ flush_para()
297
+ lst, i = parse_list(lines, i, base_line)
298
+ blocks.append(lst)
299
+ continue
300
+
301
+ para.append((ln, line))
302
+ i += 1
303
+
304
+ flush_para()
305
+ return blocks
306
+
307
+
308
+ def parse_list(lines, i, base_line):
309
+ """A list block with items at levels 0-2. Deeper items are flattened to level 2."""
310
+ n = len(lines)
311
+ items = []
312
+ stack = [] # indent of each open level
313
+ deep_warned = False
314
+ start_ln = base_line + i
315
+ while i < n:
316
+ line = expand_tabs(lines[i])
317
+ ln = base_line + i
318
+ if not line.strip():
319
+ j = i + 1
320
+ while j < n and not lines[j].strip():
321
+ j += 1
322
+ if j < n:
323
+ nxt = expand_tabs(lines[j])
324
+ base = stack[0] if stack else 0
325
+ if RE_ITEM.match(nxt) and indent_of(nxt) >= base or (items and indent_of(nxt) >= base + 2):
326
+ i = j
327
+ continue
328
+ break
329
+ m = RE_ITEM.match(line)
330
+ ind = indent_of(line)
331
+ if m and not RE_HR.match(line):
332
+ if not stack:
333
+ stack = [ind]
334
+ if ind < stack[0]:
335
+ break
336
+ while len(stack) > 1 and ind < stack[-1]:
337
+ stack.pop()
338
+ if ind >= stack[-1] + 2:
339
+ stack.append(ind)
340
+ level = len(stack) - 1
341
+ if level > 2:
342
+ if not deep_warned:
343
+ warn(ln, "list nested deeper than three levels; flattened to the third level")
344
+ deep_warned = True
345
+ level = 2
346
+ marker = m.group(2)
347
+ ordered = marker[0].isdigit()
348
+ text = m.group(3) or ""
349
+ task = None
350
+ tm = re.match(r"^\[([ xX])\][ \t]+(.*)$", text)
351
+ if tm:
352
+ task = tm.group(1).lower() == "x"
353
+ text = tm.group(2)
354
+ items.append({"level": level, "ordered": ordered, "start": int(marker[:-1]) if ordered else 1,
355
+ "paren": marker.endswith(")"), "lines": [(ln, text)], "task": task})
356
+ i += 1
357
+ continue
358
+ base = stack[0] if stack else 0
359
+ if items and (ind >= base + 2 or not RE_ATX.match(line) and not line.lstrip().startswith(">")
360
+ and not RE_FENCE.match(line) and not RE_HR.match(line)
361
+ and not line.lstrip().startswith("<!--")):
362
+ s = line.strip()
363
+ if RE_FENCE.match(s) or s.startswith("|"):
364
+ warn(ln, "code block or table inside a list item; written as plain text in the item")
365
+ items[-1]["lines"].append((ln, s))
366
+ i += 1
367
+ continue
368
+ break
369
+ # number ordered items the way PowerPoint will: a run continues past deeper items
370
+ runs = [None, None, None]
371
+ run_id = 0
372
+ jump_warned = False
373
+ for it in items:
374
+ lvl = it["level"]
375
+ for k in range(lvl + 1, 3):
376
+ runs[k] = None
377
+ if it["ordered"]:
378
+ if runs[lvl] is None:
379
+ run_id += 1
380
+ runs[lvl] = [run_id, it["start"]]
381
+ elif it["start"] != runs[lvl][1] and not jump_warned:
382
+ warn(it["lines"][0][0], "numbered list goes from %d to %d in the source; the slides number items "
383
+ "in sequence, so \"%d.\" shows as %d. Check for a missing item, and for text that refers to "
384
+ "items by number" % (runs[lvl][1] - 1, it["start"], it["start"], runs[lvl][1]))
385
+ jump_warned = True
386
+ it["num"] = runs[lvl][1]
387
+ it["run"] = (start_ln, runs[lvl][0])
388
+ runs[lvl][1] += 1
389
+ else:
390
+ runs[lvl] = None
391
+ return {"type": "list", "items": items, "line": start_ln}, i
392
+
393
+
394
+ # =========================================================================== inline parsing
395
+
396
+ RE_IMG = re.compile(r"!\[((?:[^\]\\]|\\.)*)\]\(\s*<?([^)\s>]+)>?(?:\s+[\"'][^\"']*[\"'])?\s*\)")
397
+ RE_LINK = re.compile(r"\[((?:[^\[\]\\]|\\.|\[[^\]]*\])*)\]\(\s*<?([^)\s>]*)>?(?:\s+[\"'][^\"']*[\"'])?\s*\)")
398
+ RE_REFLINK = re.compile(r"\[((?:[^\[\]\\]|\\.)+)\]\[([^\]]*)\]")
399
+ RE_SHORTREF = re.compile(r"\[((?:[^\[\]\\]|\\.)+)\](?![\[(:])")
400
+ RE_AUTOLINK = re.compile(r"<((?:https?|mailto|ftp):[^>\s]+)>")
401
+ RE_BAREURL = re.compile(r"https?://[^\s<>()]*[^\s<>().,;:!?'\"*_~]")
402
+ RE_TAG = re.compile(r"</?[A-Za-z][A-Za-z0-9-]*(\s[^<>]*)?/?>|<!--.*?-->")
403
+ RE_FOOTREF = re.compile(r"\[\^[^\]]+\]")
404
+ PUNCT = "\\`*_{}[]()#+-.!|~<>\"'$&"
405
+
406
+
407
+ class Inline(object):
408
+ def __init__(self, refs, line):
409
+ self.refs = refs
410
+ self.line = line
411
+ self.html_warned = False
412
+
413
+ def parse(self, text, style=None):
414
+ """Return segments: ('text', str, style) | ('br',) | ('img', alt, src)."""
415
+ style = dict(style or {})
416
+ out = []
417
+ buf = []
418
+
419
+ def flush():
420
+ if buf:
421
+ out.append(("text", "".join(buf), dict(style)))
422
+ del buf[:]
423
+
424
+ i, n = 0, len(text)
425
+ while i < n:
426
+ c = text[i]
427
+ rest = text[i:]
428
+ if c == "\\" and i + 1 < n and text[i + 1] in PUNCT:
429
+ buf.append(text[i + 1])
430
+ i += 2
431
+ continue
432
+ if c == "`":
433
+ run = len(rest) - len(rest.lstrip("`"))
434
+ close = text.find("`" * run, i + run)
435
+ while close != -1 and close + run < n and text[close + run] == "`":
436
+ close = text.find("`" * run, close + run + 1)
437
+ if close != -1:
438
+ flush()
439
+ code = text[i + run:close]
440
+ if code.startswith(" ") and code.endswith(" ") and code.strip():
441
+ code = code[1:-1]
442
+ st = dict(style)
443
+ st["code"] = True
444
+ out.append(("text", code, st))
445
+ i = close + run
446
+ continue
447
+ buf.append("`" * run)
448
+ i += run
449
+ continue
450
+ if c == "!" and rest.startswith("!["):
451
+ m = RE_IMG.match(rest)
452
+ if m:
453
+ flush()
454
+ out.append(("img", m.group(1), m.group(2)))
455
+ i += m.end()
456
+ continue
457
+ if c == "[":
458
+ m = RE_FOOTREF.match(rest)
459
+ if m:
460
+ warn(self.line, "footnote reference %s dropped" % m.group(0))
461
+ i += m.end()
462
+ continue
463
+ m = RE_LINK.match(rest)
464
+ url = None
465
+ if m:
466
+ label, url = m.group(1), m.group(2)
467
+ else:
468
+ m = RE_REFLINK.match(rest)
469
+ if m:
470
+ label = m.group(1)
471
+ key = (m.group(2) or m.group(1)).strip().lower()
472
+ url = self.refs.get(key)
473
+ if url is None:
474
+ m = None
475
+ if m is None:
476
+ m = RE_SHORTREF.match(rest)
477
+ if m and m.group(1).strip().lower() in self.refs:
478
+ label = m.group(1)
479
+ url = self.refs[label.strip().lower()]
480
+ else:
481
+ m = None
482
+ if m:
483
+ flush()
484
+ st = dict(style)
485
+ st["link"] = url
486
+ out.extend(self.parse(label, st))
487
+ i += m.end()
488
+ continue
489
+ if c == "<":
490
+ m = RE_AUTOLINK.match(rest)
491
+ if m:
492
+ flush()
493
+ st = dict(style)
494
+ st["link"] = m.group(1)
495
+ out.append(("text", m.group(1).replace("mailto:", ""), st))
496
+ i += m.end()
497
+ continue
498
+ m = RE_TAG.match(rest)
499
+ if m:
500
+ tag = m.group(0).lower()
501
+ if re.match(r"<br\s*/?>", tag):
502
+ flush()
503
+ out.append(("br",))
504
+ elif not self.html_warned:
505
+ warn(self.line, "inline HTML tag %s removed (text kept)" % m.group(0)[:30])
506
+ self.html_warned = True
507
+ i += m.end()
508
+ continue
509
+ if c == "h" and not style.get("link") and (i == 0 or not text[i - 1].isalnum()):
510
+ m = RE_BAREURL.match(rest)
511
+ if m:
512
+ flush()
513
+ st = dict(style)
514
+ st["link"] = m.group(0)
515
+ out.append(("text", m.group(0), st))
516
+ i += m.end()
517
+ continue
518
+ if c in "*_~":
519
+ run_char = c
520
+ run = len(rest) - len(rest.lstrip(c))
521
+ if c == "~" and run >= 2:
522
+ close = text.find("~~", i + 2)
523
+ if close > i + 2:
524
+ flush()
525
+ st = dict(style)
526
+ st["strike"] = True
527
+ out.extend(self.parse(text[i + 2:close], st))
528
+ i = close + 2
529
+ continue
530
+ elif c in "*_":
531
+ left_ok = i + run < n and not text[i + run].isspace()
532
+ if c == "_" and i > 0 and text[i - 1].isalnum():
533
+ left_ok = False
534
+ if left_ok:
535
+ for size in ((3, 2, 1) if run >= 3 else (2, 1) if run == 2 else (1,)):
536
+ delim = run_char * size
537
+ close = self._find_close(text, i + size, delim)
538
+ if close != -1:
539
+ flush()
540
+ st = dict(style)
541
+ if size >= 2:
542
+ st["bold"] = True
543
+ if size in (1, 3):
544
+ st["italic"] = True
545
+ out.extend(self.parse(text[i + size:close], st))
546
+ i = close + size
547
+ break
548
+ else:
549
+ buf.append(c * run)
550
+ i += run
551
+ continue
552
+ buf.append(c * run)
553
+ i += run
554
+ continue
555
+ if c == "\n":
556
+ if buf and ("".join(buf).endswith(" ") or "".join(buf).endswith("\\")):
557
+ s = "".join(buf).rstrip(" ").rstrip("\\")
558
+ del buf[:]
559
+ buf.append(s)
560
+ flush()
561
+ out.append(("br",))
562
+ else:
563
+ s = "".join(buf).rstrip(" ")
564
+ del buf[:]
565
+ buf.append(s + " ")
566
+ i += 1
567
+ while i < n and text[i] == " ":
568
+ i += 1
569
+ continue
570
+ buf.append(c)
571
+ i += 1
572
+ flush()
573
+ return out
574
+
575
+ @staticmethod
576
+ def _find_close(text, start, delim):
577
+ ch, size, n = delim[0], len(delim), len(text)
578
+ pos = start
579
+ while True:
580
+ close = text.find(delim, pos)
581
+ if close == -1:
582
+ return -1
583
+ run_end = close
584
+ while run_end < n and text[run_end] == ch:
585
+ run_end += 1
586
+ run = run_end - close
587
+ if close == start or text[close - 1].isspace():
588
+ pos = run_end
589
+ continue
590
+ if ch == "_" and run_end < n and text[run_end].isalnum():
591
+ pos = run_end
592
+ continue
593
+ if run == size:
594
+ return close
595
+ if run == 3 and size < 3:
596
+ return run_end - size
597
+ pos = run_end
598
+
599
+
600
+ def plain(segs):
601
+ out = []
602
+ for s in segs:
603
+ if s[0] == "text":
604
+ out.append(s[1])
605
+ elif s[0] == "br":
606
+ out.append("\n")
607
+ elif s[0] == "img":
608
+ out.append("[%s]" % s[1])
609
+ return "".join(out)
610
+
611
+
612
+ def slugify(text):
613
+ text = re.sub(r"[*_`\[\]]|\(.*?\)", "", text)
614
+ return re.sub(r"[^\w\- ]", "", text.lower()).strip().replace(" ", "-")
615
+
616
+
617
+ # =========================================================================== images
618
+
619
+ def image_size(path):
620
+ """Return (width_px, height_px, dpi_x, dpi_y, kind) or None."""
621
+ with open(path, "rb") as f:
622
+ data = f.read()
623
+ if data[:8] == b"\x89PNG\r\n\x1a\n" and len(data) >= 24:
624
+ w, h = struct.unpack(">II", data[16:24])
625
+ dx = dy = 96.0
626
+ pos = 8
627
+ while pos + 8 <= len(data):
628
+ length, ctype = struct.unpack(">I4s", data[pos:pos + 8])
629
+ if ctype == b"pHYs" and length == 9:
630
+ px, py, unit = struct.unpack(">IIB", data[pos + 8:pos + 17])
631
+ if unit == 1 and px and py:
632
+ dx, dy = px * 0.0254, py * 0.0254
633
+ break
634
+ if ctype == b"IDAT":
635
+ break
636
+ pos += 12 + length
637
+ return (w, h, dx, dy, "png") if w and h else None
638
+ if data[:2] == b"\xff\xd8":
639
+ dx = dy = 96.0
640
+ pos = 2
641
+ while pos + 4 <= len(data):
642
+ if data[pos] != 0xFF:
643
+ pos += 1
644
+ continue
645
+ marker = data[pos + 1]
646
+ if marker in (0xD8, 0x01, 0xFF) or 0xD0 <= marker <= 0xD7:
647
+ pos += 1 if marker == 0xFF else 2
648
+ continue
649
+ length = struct.unpack(">H", data[pos + 2:pos + 4])[0]
650
+ seg = data[pos + 4:pos + 2 + length]
651
+ if marker == 0xE0 and seg[:5] == b"JFIF\x00" and len(seg) >= 12:
652
+ units = seg[7]
653
+ xd, yd = struct.unpack(">HH", seg[8:12])
654
+ if units == 1 and xd and yd:
655
+ dx, dy = float(xd), float(yd)
656
+ elif units == 2 and xd and yd:
657
+ dx, dy = xd * 2.54, yd * 2.54
658
+ if 0xC0 <= marker <= 0xCF and marker not in (0xC4, 0xC8, 0xCC) and len(seg) >= 5:
659
+ h, w = struct.unpack(">HH", seg[1:5])
660
+ return (w, h, dx, dy, "jpeg") if w and h else None
661
+ pos += 2 + length
662
+ return None
663
+
664
+
665
+ # =========================================================================== geometry
666
+
667
+ EMU_IN = 914400
668
+ EMU_PT = 12700
669
+ SLIDE_H = 6858000
670
+ SLIDE_W = {"16:9": 12192000, "4:3": 9144000}
671
+ MARGIN_X = int(0.6 * EMU_IN)
672
+ TITLE_Y = int(0.35 * EMU_IN)
673
+ TITLE_H = int(1.0 * EMU_IN)
674
+ BODY_Y = int(1.45 * EMU_IN)
675
+ BODY_BOTTOM = int(7.05 * EMU_IN)
676
+ GAP = int(0.12 * EMU_IN)
677
+ INSET = 45720 # 0.05 in top and bottom inset of text frames
678
+ SIDE_INSET = 91440 # 0.1 in left and right
679
+ USABLE = 0.96 # keep a little headroom: the height model is an estimate
680
+
681
+ TITLE_PT = 32
682
+ SIZES = { # role -> (font pt, left margin in, hanging indent in, space before pt)
683
+ "p": (20, 0.0, 0.0, 8),
684
+ "quote": (20, 0.4, 0.0, 8),
685
+ "sub": (22, 0.0, 0.0, 12),
686
+ 0: (20, 0.375, 0.375, 8),
687
+ 1: (18, 0.75, 0.3, 4),
688
+ 2: (16, 1.1, 0.25, 2),
689
+ }
690
+ CODE_PT = 14
691
+ TABLE_PT = 14
692
+ CELL_MAR_X = 91440
693
+ CELL_MAR_Y = 45720
694
+ BULLETS = {0: u"\u2022", 1: u"\u2013", 2: u"\u25e6"}
695
+ NUM_SCHEMES = {0: "arabic", 1: "alphaLc", 2: "romanLc"}
696
+
697
+
698
+ def char_factor(font):
699
+ f = (font or "").lower()
700
+ if any(k in f for k in ("verdana", "tahoma", "trebuchet")):
701
+ return 0.60
702
+ if any(k in f for k in ("calibri", "aptos", "segoe", "candara", "corbel")):
703
+ return 0.50
704
+ return 0.55
705
+
706
+
707
+ def wrapped_lines(text, cpl):
708
+ total = 0
709
+ for hard in text.split("\n"):
710
+ words = hard.split()
711
+ if not words:
712
+ total += 1
713
+ continue
714
+ lines, cur = 1, 0
715
+ for w in words:
716
+ wl = len(w)
717
+ while wl > cpl: # a very long token (URL) breaks mid-word
718
+ if cur:
719
+ lines += 1
720
+ lines += wl // cpl - (1 if cur == 0 else 0)
721
+ wl = wl % cpl
722
+ cur = 0
723
+ need = wl + (1 if cur else 0)
724
+ if cur + need > cpl:
725
+ lines += 1
726
+ cur = wl
727
+ else:
728
+ cur += need
729
+ total += lines
730
+ return total
731
+
732
+
733
+ def text_lines(text, size_pt, width_emu, factor):
734
+ width_pt = width_emu / float(EMU_PT)
735
+ cpl = max(6, int(width_pt / (size_pt * factor)))
736
+ return wrapped_lines(text, cpl)
737
+
738
+
739
+ # =========================================================================== deck model
740
+
741
+ class Deck(object):
742
+ def __init__(self, src_dir, aspect, brand, refs):
743
+ self.src_dir = src_dir
744
+ self.aspect = aspect
745
+ self.brand = brand
746
+ self.refs = refs
747
+ self.width = SLIDE_W[aspect]
748
+ self.body_w = self.width - 2 * MARGIN_X
749
+ self.body_h = int((BODY_BOTTOM - BODY_Y) * USABLE)
750
+ self.body_factor = char_factor(brand["body_font"])
751
+ self.head_factor = char_factor(brand["heading_font"])
752
+ self.media = []
753
+ self.media_by_path = {}
754
+ self.counts = {"tables": 0, "images": 0, "links": 0, "code blocks": 0}
755
+
756
+ # ---- unit builders ------------------------------------------------------------
757
+ def inline(self, text, line, style=None):
758
+ return Inline(self.refs, line).parse(text, style)
759
+
760
+ def para_unit(self, role, segs, line, level=None, extra=None):
761
+ key = level if role in ("bullet", "num") else role
762
+ size, marl, _, spc = SIZES[key]
763
+ width = self.body_w - 2 * SIDE_INSET - int(marl * EMU_IN)
764
+ factor = self.head_factor * 1.06 if role == "sub" else self.body_factor
765
+ if any(s[0] == "text" and s[2].get("bold") for s in segs):
766
+ factor *= 1.03
767
+ lines = text_lines(plain(segs), size, width, factor)
768
+ h = int((lines * size * 1.2 + spc) * EMU_PT)
769
+ u = {"kind": "para", "role": role, "level": level or 0, "segs": segs, "line": line, "h": h}
770
+ if extra:
771
+ u.update(extra)
772
+ return u
773
+
774
+ def image_unit(self, alt, src, line):
775
+ alt_plain = re.sub(r"\\(.)", r"\1", alt)
776
+ fallback = [("text", "[%s]" % (alt_plain or "image"), {"italic": True})]
777
+ if re.match(r"^[a-zA-Z][a-zA-Z0-9+.-]*://", src) or src.startswith("data:"):
778
+ warn(line, "remote or inline image %s not fetched; alt text written instead" % src[:60])
779
+ return self.para_unit("p", fallback, line)
780
+ path = os.path.join(self.src_dir, src.replace("%20", " "))
781
+ if not os.path.isfile(path):
782
+ warn(line, "image %s not found; alt text written instead" % src)
783
+ return self.para_unit("p", fallback, line)
784
+ info = image_size(path)
785
+ if not info:
786
+ warn(line, "image %s is not PNG or JPEG; alt text written instead" % src)
787
+ return self.para_unit("p", fallback, line)
788
+ w, h, dx, dy, kind = info
789
+ cx, cy = w / dx * EMU_IN, h / dy * EMU_IN
790
+ scale = min(1.0, self.body_w / cx, self.body_h / cy)
791
+ return {"kind": "image", "path": os.path.abspath(path), "alt": alt_plain, "fmt": kind,
792
+ "cx": int(cx * scale), "cy": int(cy * scale), "line": line}
793
+
794
+ def table_rows(self, header, rows, aligns):
795
+ QUIET[0] = True
796
+ try:
797
+ return self._table_rows(header, rows)
798
+ finally:
799
+ QUIET[0] = False
800
+
801
+ def _table_rows(self, header, rows):
802
+ ncols = len(header)
803
+ natural = []
804
+ for c in range(ncols):
805
+ longest = max([len(plain(self.inline(r[c], 0))) for r in [header] + rows] + [3])
806
+ natural.append((min(longest, 40) + 1) * TABLE_PT * 0.6 * EMU_PT + 2 * CELL_MAR_X)
807
+ total = sum(natural)
808
+ width = int(min(self.body_w, max(total, self.body_w * 0.5)))
809
+ cols = [max(int(width * nw / total), int(0.75 * EMU_IN)) for nw in natural]
810
+ width = sum(cols)
811
+ if width > self.body_w:
812
+ cols = [int(c * self.body_w / float(width)) for c in cols]
813
+ width = sum(cols)
814
+ heights = []
815
+ for r in [header] + rows:
816
+ lines = 1
817
+ for c in range(ncols):
818
+ f = self.body_factor * (1.05 if r is header else 1.0)
819
+ lines = max(lines, text_lines(plain(self.inline(r[c], 0)), TABLE_PT,
820
+ cols[c] - 2 * CELL_MAR_X, f))
821
+ heights.append(int(lines * TABLE_PT * 1.2 * EMU_PT + 2 * CELL_MAR_Y + 2 * EMU_PT))
822
+ return cols, heights
823
+
824
+ def code_lines_h(self, lines):
825
+ width = self.body_w - 2 * SIDE_INSET
826
+ n = sum(text_lines(l or " ", CODE_PT, width, 0.6) for l in lines)
827
+ return int(n * CODE_PT * 1.2 * EMU_PT) + 2 * int(0.08 * EMU_IN)
828
+
829
+ def units_from_blocks(self, blocks, quote=False):
830
+ units = []
831
+ for b in blocks:
832
+ t, ln = b["type"], b["line"]
833
+ if t == "heading":
834
+ segs = self.inline(b["text"], ln, {"bold": True})
835
+ units.append(self.para_unit("sub", segs, ln, extra={"slug": slugify(b["text"])}))
836
+ elif t == "para":
837
+ text = "\n".join(l for _, l in b["lines"]).strip()
838
+ if RE_NOTES_PARA.match(text):
839
+ warn(ln, "paragraph starting \"Notes:\" stays on the slide as text; wrap it in "
840
+ "<!-- notes: ... --> to make it speaker notes")
841
+ segs = self.inline(text, ln)
842
+ imgs = [s for s in segs if s[0] == "img"]
843
+ rest = [s for s in segs if s[0] != "img"]
844
+ if plain(rest).strip():
845
+ if imgs:
846
+ warn(ln, "image inside a text paragraph placed below the paragraph")
847
+ units.append(self.para_unit("quote" if quote else "p", rest, ln))
848
+ for s in imgs:
849
+ units.append(self.image_unit(s[1], s[2], ln))
850
+ elif t == "list":
851
+ for it in b["items"]:
852
+ iln = it["lines"][0][0]
853
+ segs = self.inline("\n".join(tx for _, tx in it["lines"]), iln)
854
+ if any(s[0] == "img" for s in segs):
855
+ warn(iln, "image inside a list item not embedded; alt text written instead")
856
+ if it["task"] is not None:
857
+ segs.insert(0, ("text", u"\u2612 " if it["task"] else u"\u2610 ", {}))
858
+ role = "num" if it["ordered"] else "bullet"
859
+ extra = {"num": it.get("num"), "run": it.get("run"), "paren": it["paren"]}
860
+ units.append(self.para_unit(role, segs, iln, it["level"], extra))
861
+ elif t == "code":
862
+ self.counts["code blocks"] += 1
863
+ units.append({"kind": "code", "lines": b["text"].split("\n"), "line": ln})
864
+ elif t == "quote":
865
+ units.extend(self.units_from_blocks(b["blocks"], quote=True))
866
+ elif t == "table":
867
+ self.counts["tables"] += 1
868
+ if len(b["header"]) > 8:
869
+ warn(ln, "table has %d columns; at 14 pt the cells wrap heavily. Split it into two "
870
+ "tables or move it to an appendix" % len(b["header"]))
871
+ cols, heights = self.table_rows(b["header"], b["rows"], b["aligns"])
872
+ units.append({"kind": "table", "header": b["header"], "rows": b["rows"], "aligns": b["aligns"],
873
+ "cols": cols, "heights": heights, "line": ln})
874
+ elif t == "notes":
875
+ units.append({"kind": "notes", "text": b["text"], "line": ln})
876
+ elif t == "hr":
877
+ units.append({"kind": "break", "line": ln})
878
+ return units
879
+
880
+ # ---- pagination ---------------------------------------------------------------
881
+ def paginate(self, units, title, line):
882
+ """Pack units into pages of body height. Returns (pages, split_reason or None)."""
883
+ H = self.body_h
884
+ pages = [[]]
885
+ used = [0]
886
+ last = [None]
887
+ reasons = []
888
+
889
+ def new_page():
890
+ if pages[-1] and any(u["kind"] != "notes" for u in pages[-1]):
891
+ pages.append([])
892
+ used[0] = 0
893
+ last[0] = None
894
+
895
+ def cost(u, h):
896
+ if u["kind"] == "para" and last[0] == "para":
897
+ return h
898
+ return (GAP if used[0] else 0) + (2 * INSET if u["kind"] == "para" else 0) + h
899
+
900
+ def place(u, h):
901
+ used[0] += cost(u, h)
902
+ last[0] = u["kind"]
903
+ pages[-1].append(u)
904
+
905
+ for u in units:
906
+ k = u["kind"]
907
+ if k == "notes":
908
+ pages[-1].append(u)
909
+ continue
910
+ if k == "break":
911
+ new_page()
912
+ continue
913
+ if k == "para":
914
+ if used[0] + cost(u, u["h"]) > H and used[0]:
915
+ carry = []
916
+ # keep a subheading, or a lead-in line ending in ":", with what follows it
917
+ while pages[-1] and pages[-1][-1]["kind"] == "para" and (
918
+ pages[-1][-1]["role"] == "sub" or plain(pages[-1][-1]["segs"]).rstrip().endswith(":")) \
919
+ and len([p for p in pages[-1] if p["kind"] != "notes"]) > 1:
920
+ carry.insert(0, pages[-1].pop())
921
+ reasons.append("text")
922
+ pages.append([])
923
+ used[0] = 0
924
+ last[0] = None
925
+ for c in carry:
926
+ place(c, c["h"])
927
+ if not used[0] and cost(u, u["h"]) > H:
928
+ warn(u["line"], "one paragraph is taller than a slide and will overflow it; "
929
+ "break it into bullets or shorten it")
930
+ place(u, u["h"])
931
+ elif k == "image":
932
+ full = u["cy"]
933
+ room = H - used[0] - (GAP if used[0] else 0)
934
+ if room >= full:
935
+ place(u, full)
936
+ elif used[0] and room >= max(0.6 * full, 1.5 * EMU_IN):
937
+ s = room / float(full)
938
+ u["cx"], u["cy"] = int(u["cx"] * s), int(u["cy"] * s)
939
+ place(u, u["cy"])
940
+ else:
941
+ reasons.append("image")
942
+ new_page()
943
+ place(u, full)
944
+ elif k == "code":
945
+ lines = u["lines"]
946
+ while lines:
947
+ room = H - used[0] - (GAP if used[0] else 0)
948
+ take = len(lines)
949
+ while take > 0 and self.code_lines_h(lines[:take]) > room:
950
+ take -= 1
951
+ if take == len(lines) or (take >= 3 and used[0]) or not used[0]:
952
+ if take == 0:
953
+ take = 1
954
+ left = len(lines) - take
955
+ if 0 < left < 3 and take > 6: # don't strand one or two lines on a slide
956
+ take -= 3 - left
957
+ chunk = dict(u, lines=lines[:take])
958
+ place(chunk, self.code_lines_h(chunk["lines"]))
959
+ lines = lines[take:]
960
+ if lines:
961
+ reasons.append("code")
962
+ new_page()
963
+ else:
964
+ reasons.append("code")
965
+ new_page()
966
+ elif k == "table":
967
+ rows = list(range(len(u["rows"])))
968
+ hh = u["heights"][0]
969
+ first = True
970
+ while first or rows:
971
+ first = False
972
+ room = H - used[0] - (GAP if used[0] else 0)
973
+ take, h = 0, hh
974
+ while take < len(rows) and h + u["heights"][rows[take] + 1] <= room:
975
+ h += u["heights"][rows[take] + 1]
976
+ take += 1
977
+ if take == len(rows) or (take >= 2 and used[0]) or not used[0]:
978
+ if take == 0 and rows:
979
+ take = 1
980
+ left = len(rows) - take
981
+ if left == 1 and take > 4: # don't strand one row under a repeated header
982
+ take -= 1
983
+ h = hh + sum(u["heights"][r + 1] for r in rows[:take])
984
+ chunk = dict(u, rows=[u["rows"][r] for r in rows[:take]],
985
+ heights=[hh] + [u["heights"][r + 1] for r in rows[:take]])
986
+ if h > H:
987
+ warn(u["line"], "a table row is taller than a slide and will overflow it")
988
+ place(chunk, h)
989
+ rows = rows[take:]
990
+ if rows:
991
+ reasons.append("table")
992
+ new_page()
993
+ else:
994
+ reasons.append("table")
995
+ new_page()
996
+ if not pages[-1] and len(pages) > 1:
997
+ pages.pop()
998
+ return pages, reasons
999
+
1000
+
1001
+ # =========================================================================== XML writers
1002
+
1003
+ NS = ('xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" '
1004
+ 'xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" '
1005
+ 'xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main"')
1006
+ XML_HEAD = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n'
1007
+ REL = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/"
1008
+ CT = "application/vnd.openxmlformats-officedocument.presentationml."
1009
+ GRP = ('<p:nvGrpSpPr><p:cNvPr id="1" name=""/><p:cNvGrpSpPr/><p:nvPr/></p:nvGrpSpPr>'
1010
+ '<p:grpSpPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/><a:chOff x="0" y="0"/>'
1011
+ '<a:chExt cx="0" cy="0"/></a:xfrm></p:grpSpPr>')
1012
+
1013
+
1014
+ def xfrm(xo, yo, cx, cy):
1015
+ return '<a:xfrm><a:off x="%d" y="%d"/><a:ext cx="%d" cy="%d"/></a:xfrm>' % (xo, yo, cx, cy)
1016
+
1017
+
1018
+ def rels_xml(rels):
1019
+ rows = []
1020
+ for rid, kind, target, ext in rels:
1021
+ rows.append('<Relationship Id="%s" Type="%s%s" Target="%s"%s/>' % (
1022
+ rid, REL, kind, x(target), ' TargetMode="External"' if ext else ""))
1023
+ return (XML_HEAD + '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
1024
+ '%s</Relationships>') % "".join(rows)
1025
+
1026
+
1027
+ class SlideOut(object):
1028
+ def __init__(self, deck, layout, anchors, number):
1029
+ self.deck = deck
1030
+ self.rels = [("rId1", "slideLayout", "../slideLayouts/slideLayout%d.xml" % layout, False)]
1031
+ self.shapes = []
1032
+ self.next_id = 2
1033
+ self.anchors = anchors
1034
+ self.number = number
1035
+ self.notes = []
1036
+ self.layout = layout
1037
+
1038
+ def add_rel(self, kind, target, ext=False):
1039
+ for rid, k, t, e in self.rels:
1040
+ if k == kind and t == target and e == ext:
1041
+ return rid
1042
+ rid = "rId%d" % (len(self.rels) + 1)
1043
+ self.rels.append((rid, kind, target, ext))
1044
+ return rid
1045
+
1046
+ def sid(self):
1047
+ i = self.next_id
1048
+ self.next_id += 1
1049
+ return i
1050
+
1051
+ # ---- runs
1052
+ def runs(self, segs, size, line, color=None, font=None):
1053
+ out = []
1054
+ sz = ' sz="%d"' % int(size * 100) if size else ""
1055
+ for seg in segs:
1056
+ if seg[0] == "br":
1057
+ out.append('<a:br><a:rPr lang="en-US"%s/></a:br>' % sz)
1058
+ continue
1059
+ if seg[0] == "img":
1060
+ warn(line, "image inside a list item or table cell not embedded; alt text written instead")
1061
+ seg = ("text", "[%s]" % (seg[1] or "image"), {"italic": True})
1062
+ _, text, st = seg
1063
+ if not text:
1064
+ continue
1065
+ attrs = ' lang="en-US"' + sz
1066
+ if st.get("bold"):
1067
+ attrs += ' b="1"'
1068
+ if st.get("italic"):
1069
+ attrs += ' i="1"'
1070
+ if st.get("strike"):
1071
+ attrs += ' strike="sngStrike"'
1072
+ kids = ""
1073
+ if color:
1074
+ kids += '<a:solidFill><a:srgbClr val="%s"/></a:solidFill>' % color
1075
+ if st.get("code"):
1076
+ kids += '<a:latin typeface="Consolas" pitchFamily="49" charset="0"/>' \
1077
+ '<a:cs typeface="Consolas" pitchFamily="49" charset="0"/>'
1078
+ elif font:
1079
+ kids += '<a:latin typeface="%s"/>' % x(font)
1080
+ link = st.get("link")
1081
+ if link is not None:
1082
+ kids += self.link_xml(link, line)
1083
+ out.append('<a:r><a:rPr%s>%s</a:rPr><a:t>%s</a:t></a:r>' % (attrs, kids, x(text)))
1084
+ return "".join(out)
1085
+
1086
+ def link_xml(self, url, line):
1087
+ if not url:
1088
+ return ""
1089
+ if url.startswith("#"):
1090
+ target = self.anchors.get(url[1:].lower())
1091
+ if not target:
1092
+ warn(line, "link to #%s has no matching heading; kept as plain text" % url[1:])
1093
+ return ""
1094
+ self.deck.counts["links"] += 1
1095
+ rid = self.add_rel("slide", "slide%d.xml" % target)
1096
+ return '<a:hlinkClick r:id="%s" action="ppaction://hlinksldjump"/>' % rid
1097
+ if not re.match(r"^[a-zA-Z][a-zA-Z0-9+.-]*:", url):
1098
+ warn(line, "link to local file %s kept as a link, but the file is not inside the deck; "
1099
+ "it opens only where that path exists" % url[:60])
1100
+ self.deck.counts["links"] += 1
1101
+ rid = self.add_rel("hyperlink", quote(url, safe=":/?#[]@!$&'()*+,;=%~-._"), ext=True)
1102
+ return '<a:hlinkClick r:id="%s"/>' % rid
1103
+
1104
+ # ---- shapes
1105
+ def title(self, segs, line, ctr=False, sub_lines=None):
1106
+ ph = '<p:ph type="ctrTitle"/>' if ctr else '<p:ph type="title"/>'
1107
+ body = self.runs(segs, None, line)
1108
+ self.shapes.append(
1109
+ '<p:sp><p:nvSpPr><p:cNvPr id="%d" name="Title %d"/><p:cNvSpPr><a:spLocks noGrp="1"/></p:cNvSpPr>'
1110
+ '<p:nvPr>%s</p:nvPr></p:nvSpPr><p:spPr/><p:txBody><a:bodyPr/><a:lstStyle/><a:p>%s'
1111
+ '<a:endParaRPr lang="en-US"/></a:p></p:txBody></p:sp>' % (self.sid(), self.next_id - 2, ph, body))
1112
+ if sub_lines:
1113
+ paras = "".join('<a:p>%s</a:p>' % self.runs(s, None, line) for s in sub_lines)
1114
+ self.shapes.append(
1115
+ '<p:sp><p:nvSpPr><p:cNvPr id="%d" name="Subtitle %d"/><p:cNvSpPr><a:spLocks noGrp="1"/>'
1116
+ '</p:cNvSpPr><p:nvPr><p:ph type="subTitle" idx="1"/></p:nvPr></p:nvSpPr><p:spPr/>'
1117
+ '<p:txBody><a:bodyPr/><a:lstStyle/>%s</p:txBody></p:sp>' % (self.sid(), self.next_id - 2, paras))
1118
+
1119
+ def para_xml(self, u, startat):
1120
+ role, lvl = u["role"], u["level"]
1121
+ key = lvl if role in ("bullet", "num") else role
1122
+ size, marl, hang, spc = SIZES[key]
1123
+ color = None
1124
+ attrs = ' marL="%d" indent="%d"' % (int(marl * EMU_IN), -int(hang * EMU_IN))
1125
+ if role in ("bullet", "num"):
1126
+ attrs += ' lvl="%d"' % lvl
1127
+ inner = '<a:lnSpc><a:spcPct val="100000"/></a:lnSpc><a:spcBef><a:spcPts val="%d"/></a:spcBef>' % (spc * 100)
1128
+ if role == "bullet":
1129
+ inner += ('<a:buClr><a:schemeClr val="accent1"/></a:buClr><a:buFont typeface="Arial"/>'
1130
+ '<a:buChar char="%s"/>' % BULLETS[lvl])
1131
+ elif role == "num":
1132
+ scheme = NUM_SCHEMES[lvl] + ("ParenR" if u.get("paren") else "Period")
1133
+ inner += '<a:buClr><a:schemeClr val="accent1"/></a:buClr><a:buFont typeface="+mj-lt"/>' \
1134
+ '<a:buAutoNum type="%s"%s/>' % (scheme, ' startAt="%d"' % startat if startat != 1 else "")
1135
+ else:
1136
+ inner += '<a:buNone/>'
1137
+ segs = u["segs"]
1138
+ if role == "quote":
1139
+ segs = [(s[0], s[1], dict(s[2], italic=True)) if s[0] == "text" else s for s in segs]
1140
+ color = "595959"
1141
+ if role == "sub":
1142
+ color = self.deck.brand.get("primary_for_text", self.deck.brand["primary"])
1143
+ font = self.deck.brand["heading_font"] if role == "sub" else None
1144
+ return '<a:p><a:pPr%s>%s</a:pPr>%s<a:endParaRPr lang="en-US" sz="%d"/></a:p>' % (
1145
+ attrs, inner, self.runs(segs, size, u["line"], color, font), size * 100)
1146
+
1147
+ def text_frame(self, paras, y, h, placeholder):
1148
+ starts = {}
1149
+ out = []
1150
+ for u in paras:
1151
+ startat = 1
1152
+ if u["role"] == "num":
1153
+ run = u.get("run")
1154
+ if run not in starts:
1155
+ starts[run] = u.get("num") or 1
1156
+ startat = starts[run]
1157
+ out.append(self.para_xml(u, startat))
1158
+ sid = self.sid()
1159
+ body = ('<p:txBody><a:bodyPr wrap="square" lIns="%d" tIns="%d" rIns="%d" bIns="%d" anchor="t">'
1160
+ '<a:normAutofit/></a:bodyPr><a:lstStyle/>%s</p:txBody>') % (
1161
+ SIDE_INSET, INSET, SIDE_INSET, INSET, "".join(out))
1162
+ geo = xfrm(MARGIN_X, y, self.deck.body_w, h)
1163
+ if placeholder:
1164
+ self.shapes.append(
1165
+ '<p:sp><p:nvSpPr><p:cNvPr id="%d" name="Content %d"/><p:cNvSpPr><a:spLocks noGrp="1"/>'
1166
+ '</p:cNvSpPr><p:nvPr><p:ph idx="1"/></p:nvPr></p:nvSpPr><p:spPr>%s</p:spPr>%s</p:sp>'
1167
+ % (sid, sid - 1, geo, body))
1168
+ else:
1169
+ self.shapes.append(
1170
+ '<p:sp><p:nvSpPr><p:cNvPr id="%d" name="Text %d"/><p:cNvSpPr txBox="1"/><p:nvPr/></p:nvSpPr>'
1171
+ '<p:spPr>%s<a:prstGeom prst="rect"><a:avLst/></a:prstGeom><a:noFill/></p:spPr>%s</p:sp>'
1172
+ % (sid, sid - 1, geo, body))
1173
+
1174
+ def code_box(self, u, y, h):
1175
+ sid = self.sid()
1176
+ paras = []
1177
+ for l in u["lines"]:
1178
+ l = l.replace("\t", " ")
1179
+ run = ('<a:r><a:rPr lang="en-US" sz="%d"><a:solidFill><a:srgbClr val="1F1F1F"/></a:solidFill>'
1180
+ '<a:latin typeface="Consolas" pitchFamily="49" charset="0"/>'
1181
+ '<a:cs typeface="Consolas" pitchFamily="49" charset="0"/></a:rPr><a:t>%s</a:t></a:r>'
1182
+ % (CODE_PT * 100, x(l))) if l else ""
1183
+ paras.append('<a:p><a:pPr marL="0" indent="0"><a:lnSpc><a:spcPct val="100000"/></a:lnSpc>'
1184
+ '<a:spcBef><a:spcPts val="0"/></a:spcBef><a:buNone/></a:pPr>%s'
1185
+ '<a:endParaRPr lang="en-US" sz="%d"/></a:p>' % (run, CODE_PT * 100))
1186
+ ins = int(0.08 * EMU_IN)
1187
+ self.shapes.append(
1188
+ '<p:sp><p:nvSpPr><p:cNvPr id="%d" name="Code %d"/><p:cNvSpPr txBox="1"/><p:nvPr/></p:nvSpPr>'
1189
+ '<p:spPr>%s<a:prstGeom prst="rect"><a:avLst/></a:prstGeom><a:solidFill><a:srgbClr val="F2F2F2"/>'
1190
+ '</a:solidFill><a:ln w="6350"><a:solidFill><a:srgbClr val="D9D9D9"/></a:solidFill></a:ln></p:spPr>'
1191
+ '<p:txBody><a:bodyPr wrap="square" lIns="%d" tIns="%d" rIns="%d" bIns="%d" anchor="t"><a:noAutofit/>'
1192
+ '</a:bodyPr><a:lstStyle/>%s</p:txBody></p:sp>'
1193
+ % (sid, sid - 1, xfrm(MARGIN_X, y, self.deck.body_w, h), SIDE_INSET, ins, SIDE_INSET, ins, "".join(paras)))
1194
+
1195
+ def table(self, u, y):
1196
+ sid = self.sid()
1197
+ cols, heights = u["cols"], u["heights"]
1198
+ width = sum(cols)
1199
+ xo = MARGIN_X + (self.deck.body_w - width) // 2
1200
+ brand = self.deck.brand
1201
+ head_text = brand["on_primary"]
1202
+ border = ('<a:%s w="9525" cap="flat" cmpd="sng" algn="ctr"><a:solidFill><a:srgbClr val="BFBFBF"/>'
1203
+ '</a:solidFill><a:prstDash val="solid"/></a:%s>')
1204
+ lines = "".join(border % (e, e) for e in ("lnL", "lnR", "lnT", "lnB"))
1205
+ rows_xml = []
1206
+ for r_i, row in enumerate([u["header"]] + u["rows"]):
1207
+ head = r_i == 0
1208
+ fill = brand["primary"] if head else ("FFFFFF" if r_i % 2 else "F2F2F2")
1209
+ cells = []
1210
+ for c_i, cell in enumerate(row):
1211
+ segs = Inline(self.deck.refs, u["line"]).parse(cell, {"bold": True} if head else None)
1212
+ cells.append(
1213
+ '<a:tc><a:txBody><a:bodyPr/><a:lstStyle/><a:p><a:pPr algn="%s" marL="0" indent="0"><a:buNone/>'
1214
+ '</a:pPr>%s<a:endParaRPr lang="en-US" sz="%d"/></a:p></a:txBody>'
1215
+ '<a:tcPr marL="%d" marR="%d" marT="%d" marB="%d" anchor="ctr">%s<a:solidFill>'
1216
+ '<a:srgbClr val="%s"/></a:solidFill></a:tcPr></a:tc>' % (
1217
+ u["aligns"][c_i], self.runs(segs, TABLE_PT, u["line"], head_text if head else None),
1218
+ TABLE_PT * 100, CELL_MAR_X, CELL_MAR_X, CELL_MAR_Y, CELL_MAR_Y, lines, fill))
1219
+ rows_xml.append('<a:tr h="%d">%s</a:tr>' % (heights[r_i], "".join(cells)))
1220
+ grid = "".join('<a:gridCol w="%d"/>' % c for c in cols)
1221
+ self.shapes.append(
1222
+ '<p:graphicFrame><p:nvGraphicFramePr><p:cNvPr id="%d" name="Table %d"/><p:cNvGraphicFramePr>'
1223
+ '<a:graphicFrameLocks noGrp="1"/></p:cNvGraphicFramePr><p:nvPr/></p:nvGraphicFramePr>'
1224
+ '<p:xfrm><a:off x="%d" y="%d"/><a:ext cx="%d" cy="%d"/></p:xfrm><a:graphic>'
1225
+ '<a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/table"><a:tbl>'
1226
+ '<a:tblPr firstRow="1" bandRow="1"><a:tableStyleId>{2D5ABB26-0587-4C30-8999-92F81FD0307C}'
1227
+ '</a:tableStyleId></a:tblPr><a:tblGrid>%s</a:tblGrid>%s</a:tbl></a:graphicData></a:graphic>'
1228
+ '</p:graphicFrame>' % (sid, sid - 1, xo, y, width, sum(heights), grid, "".join(rows_xml)))
1229
+
1230
+ def picture(self, u, y):
1231
+ deck = self.deck
1232
+ name = deck.media_by_path.get(u["path"])
1233
+ if not name:
1234
+ name = "image%d.%s" % (len(deck.media) + 1, u["fmt"])
1235
+ with open(u["path"], "rb") as f:
1236
+ deck.media.append(("ppt/media/" + name, f.read()))
1237
+ deck.media_by_path[u["path"]] = name
1238
+ deck.counts["images"] += 1
1239
+ rid = self.add_rel("image", "../media/" + name)
1240
+ sid = self.sid()
1241
+ xo = MARGIN_X + (deck.body_w - u["cx"]) // 2
1242
+ self.shapes.append(
1243
+ '<p:pic><p:nvPicPr><p:cNvPr id="%d" name="Picture %d" descr="%s"/><p:cNvPicPr>'
1244
+ '<a:picLocks noChangeAspect="1"/></p:cNvPicPr><p:nvPr/></p:nvPicPr><p:blipFill>'
1245
+ '<a:blip r:embed="%s"/><a:stretch><a:fillRect/></a:stretch></p:blipFill><p:spPr>%s'
1246
+ '<a:prstGeom prst="rect"><a:avLst/></a:prstGeom></p:spPr></p:pic>'
1247
+ % (sid, sid - 1, x(u["alt"]), rid, xfrm(xo, y, u["cx"], u["cy"])))
1248
+
1249
+ def layout_body(self, units):
1250
+ y = BODY_Y
1251
+ placeholder_used = False
1252
+ i = 0
1253
+ while i < len(units):
1254
+ u = units[i]
1255
+ k = u["kind"]
1256
+ if k == "notes":
1257
+ self.notes.append(u["text"])
1258
+ i += 1
1259
+ continue
1260
+ if y > BODY_Y:
1261
+ y += GAP
1262
+ if k == "para":
1263
+ group = []
1264
+ while i < len(units) and units[i]["kind"] in ("para", "notes"):
1265
+ if units[i]["kind"] == "notes":
1266
+ self.notes.append(units[i]["text"])
1267
+ else:
1268
+ group.append(units[i])
1269
+ i += 1
1270
+ h = sum(p["h"] for p in group) + 2 * INSET
1271
+ self.text_frame(group, y, h, not placeholder_used)
1272
+ placeholder_used = True
1273
+ y += h
1274
+ continue
1275
+ if k == "code":
1276
+ h = self.deck.code_lines_h(u["lines"])
1277
+ self.code_box(u, y, h)
1278
+ y += h
1279
+ elif k == "table":
1280
+ self.table(u, y)
1281
+ y += sum(u["heights"])
1282
+ elif k == "image":
1283
+ self.picture(u, y)
1284
+ y += u["cy"]
1285
+ i += 1
1286
+
1287
+ def xml(self):
1288
+ return (XML_HEAD + '<p:sld %s><p:cSld><p:spTree>%s%s</p:spTree></p:cSld>'
1289
+ '<p:clrMapOvr><a:masterClrMapping/></p:clrMapOvr></p:sld>') % (NS, GRP, "".join(self.shapes))
1290
+
1291
+
1292
+ def notes_xml(texts, width):
1293
+ paras = []
1294
+ for t in texts:
1295
+ for line in t.split("\n"):
1296
+ paras.append('<a:p><a:r><a:rPr lang="en-US"/><a:t>%s</a:t></a:r></a:p>' % x(line) if line.strip()
1297
+ else '<a:p><a:endParaRPr lang="en-US"/></a:p>')
1298
+ img_h = int(5486400 * SLIDE_H / float(width))
1299
+ return (XML_HEAD + '<p:notes %s><p:cSld><p:spTree>%s'
1300
+ '<p:sp><p:nvSpPr><p:cNvPr id="2" name="Slide Image Placeholder 1"/><p:cNvSpPr><a:spLocks noGrp="1" '
1301
+ 'noRot="1" noChangeAspect="1"/></p:cNvSpPr><p:nvPr><p:ph type="sldImg"/></p:nvPr></p:nvSpPr>'
1302
+ '<p:spPr>%s</p:spPr></p:sp>'
1303
+ '<p:sp><p:nvSpPr><p:cNvPr id="3" name="Notes Placeholder 2"/><p:cNvSpPr><a:spLocks noGrp="1"/>'
1304
+ '</p:cNvSpPr><p:nvPr><p:ph type="body" idx="1"/></p:nvPr></p:nvSpPr><p:spPr>%s</p:spPr>'
1305
+ '<p:txBody><a:bodyPr/><a:lstStyle/>%s</p:txBody></p:sp>'
1306
+ '</p:spTree></p:cSld><p:clrMapOvr><a:masterClrMapping/></p:clrMapOvr></p:notes>') % (
1307
+ NS, GRP, xfrm(685800, 1143000, 5486400, img_h), xfrm(685800, 4400550, 5486400, 3600450),
1308
+ "".join(paras))
1309
+
1310
+
1311
+ # ---- fixed parts --------------------------------------------------------------------
1312
+
1313
+ def theme_xml(brand, name):
1314
+ accents = [brand["primary"], brand["accent"], "A5A5A5", "FFC000", "5B9BD5", "70AD47"]
1315
+ clr = ('<a:dk1><a:srgbClr val="%s"/></a:dk1><a:lt1><a:srgbClr val="FFFFFF"/></a:lt1>'
1316
+ '<a:dk2><a:srgbClr val="%s"/></a:dk2><a:lt2><a:srgbClr val="E7E6E6"/></a:lt2>' % (brand["text"], brand["primary"]))
1317
+ clr += "".join('<a:accent%d><a:srgbClr val="%s"/></a:accent%d>' % (i + 1, c, i + 1) for i, c in enumerate(accents))
1318
+ clr += '<a:hlink><a:srgbClr val="0563C1"/></a:hlink><a:folHlink><a:srgbClr val="954F72"/></a:folHlink>'
1319
+ ln = ('<a:ln w="%d" cap="flat" cmpd="sng" algn="ctr"><a:solidFill><a:schemeClr val="phClr"/></a:solidFill>'
1320
+ '<a:prstDash val="solid"/><a:miter lim="800000"/></a:ln>')
1321
+ solid = '<a:solidFill><a:schemeClr val="phClr"/></a:solidFill>'
1322
+ return (XML_HEAD + '<a:theme xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" name="%s">'
1323
+ '<a:themeElements><a:clrScheme name="%s">%s</a:clrScheme>'
1324
+ '<a:fontScheme name="%s"><a:majorFont><a:latin typeface="%s"/><a:ea typeface=""/><a:cs typeface=""/>'
1325
+ '</a:majorFont><a:minorFont><a:latin typeface="%s"/><a:ea typeface=""/><a:cs typeface=""/></a:minorFont>'
1326
+ '</a:fontScheme><a:fmtScheme name="%s"><a:fillStyleLst>%s</a:fillStyleLst><a:lnStyleLst>%s</a:lnStyleLst>'
1327
+ '<a:effectStyleLst>%s</a:effectStyleLst><a:bgFillStyleLst>%s</a:bgFillStyleLst></a:fmtScheme>'
1328
+ '</a:themeElements><a:objectDefaults/><a:extraClrSchemeLst/></a:theme>') % (
1329
+ x(name), x(name), clr, x(name), x(brand["heading_font"]), x(brand["body_font"]), x(name),
1330
+ solid * 3, ln % 6350 + ln % 12700 + ln % 19050,
1331
+ '<a:effectStyle><a:effectLst/></a:effectStyle>' * 3, solid * 3)
1332
+
1333
+
1334
+ def text_styles():
1335
+ def lvl(n, marl, ind, sz, char, spc):
1336
+ return ('<a:lvl%dpPr marL="%d" indent="%d" algn="l" defTabSz="914400" rtl="0" eaLnBrk="1" latinLnBrk="0" '
1337
+ 'hangingPunct="1"><a:lnSpc><a:spcPct val="100000"/></a:lnSpc><a:spcBef><a:spcPts val="%d"/>'
1338
+ '</a:spcBef><a:buFont typeface="Arial"/><a:buChar char="%s"/><a:defRPr sz="%d" kern="1200">'
1339
+ '<a:solidFill><a:schemeClr val="tx1"/></a:solidFill><a:latin typeface="+mn-lt"/>'
1340
+ '<a:ea typeface="+mn-ea"/><a:cs typeface="+mn-cs"/></a:defRPr></a:lvl%dpPr>') % (
1341
+ n, marl, ind, spc, char, sz, n)
1342
+ body = "".join(lvl(i + 1, int(SIZES[i][1] * EMU_IN), -int(SIZES[i][2] * EMU_IN), SIZES[i][0] * 100,
1343
+ BULLETS[i], SIZES[i][3] * 100) for i in range(3))
1344
+ title = ('<p:titleStyle><a:lvl1pPr algn="l" defTabSz="914400" rtl="0" eaLnBrk="1" latinLnBrk="0" '
1345
+ 'hangingPunct="1"><a:lnSpc><a:spcPct val="90000"/></a:lnSpc><a:spcBef><a:spcPct val="0"/></a:spcBef>'
1346
+ '<a:buNone/><a:defRPr sz="%d" b="1" kern="1200"><a:solidFill><a:schemeClr val="tx2"/></a:solidFill>'
1347
+ '<a:latin typeface="+mj-lt"/><a:ea typeface="+mj-ea"/><a:cs typeface="+mj-cs"/></a:defRPr>'
1348
+ '</a:lvl1pPr></p:titleStyle>') % (TITLE_PT * 100)
1349
+ other = ('<p:otherStyle><a:defPPr><a:defRPr lang="en-US"/></a:defPPr><a:lvl1pPr marL="0" algn="l" '
1350
+ 'defTabSz="914400" rtl="0" eaLnBrk="1" latinLnBrk="0" hangingPunct="1"><a:defRPr sz="1800" kern="1200">'
1351
+ '<a:solidFill><a:schemeClr val="tx1"/></a:solidFill><a:latin typeface="+mn-lt"/><a:ea typeface="+mn-ea"/>'
1352
+ '<a:cs typeface="+mn-cs"/></a:defRPr></a:lvl1pPr></p:otherStyle>')
1353
+ return '<p:txStyles>%s<p:bodyStyle>%s</p:bodyStyle>%s</p:txStyles>' % (title, body, other)
1354
+
1355
+
1356
+ def ph_sp(sid, name, ph, geo, body_pr, lst, text):
1357
+ return ('<p:sp><p:nvSpPr><p:cNvPr id="%d" name="%s"/><p:cNvSpPr><a:spLocks noGrp="1"/></p:cNvSpPr>'
1358
+ '<p:nvPr>%s</p:nvPr></p:nvSpPr><p:spPr>%s<a:prstGeom prst="rect"><a:avLst/></a:prstGeom></p:spPr>'
1359
+ '<p:txBody>%s<a:lstStyle>%s</a:lstStyle><a:p><a:r><a:rPr lang="en-US"/><a:t>%s</a:t></a:r></a:p>'
1360
+ '</p:txBody></p:sp>') % (sid, name, ph, geo, body_pr, lst, text)
1361
+
1362
+
1363
+ CLRMAP = ('bg1="lt1" tx1="dk1" bg2="lt2" tx2="dk2" accent1="accent1" accent2="accent2" accent3="accent3" '
1364
+ 'accent4="accent4" accent5="accent5" accent6="accent6" hlink="hlink" folHlink="folHlink"')
1365
+
1366
+
1367
+ def master_xml(width):
1368
+ bw = width - 2 * MARGIN_X
1369
+ title = ph_sp(2, "Title Placeholder 1", '<p:ph type="title"/>', xfrm(MARGIN_X, TITLE_Y, bw, TITLE_H),
1370
+ '<a:bodyPr vert="horz" lIns="91440" tIns="45720" rIns="91440" bIns="45720" rtlCol="0" anchor="b">'
1371
+ '<a:normAutofit/></a:bodyPr>', "", "Click to edit Master title style")
1372
+ body = ph_sp(3, "Text Placeholder 2", '<p:ph type="body" idx="1"/>',
1373
+ xfrm(MARGIN_X, BODY_Y, bw, BODY_BOTTOM - BODY_Y),
1374
+ '<a:bodyPr vert="horz" lIns="91440" tIns="45720" rIns="91440" bIns="45720" rtlCol="0">'
1375
+ '<a:normAutofit/></a:bodyPr>', "", "Click to edit Master text styles")
1376
+ rule = ('<p:cxnSp><p:nvCxnSpPr><p:cNvPr id="4" name="Title Rule"/><p:cNvCxnSpPr/><p:nvPr userDrawn="1"/>'
1377
+ '</p:nvCxnSpPr><p:spPr>%s<a:prstGeom prst="line"><a:avLst/></a:prstGeom><a:ln w="19050">'
1378
+ '<a:solidFill><a:schemeClr val="accent2"/></a:solidFill></a:ln></p:spPr></p:cxnSp>') % xfrm(
1379
+ MARGIN_X + SIDE_INSET, TITLE_Y + TITLE_H + 45720, int(1.2 * EMU_IN), 0)
1380
+ return (XML_HEAD + '<p:sldMaster %s><p:cSld><p:bg><p:bgRef idx="1001"><a:schemeClr val="bg1"/></p:bgRef>'
1381
+ '</p:bg><p:spTree>%s%s%s%s</p:spTree></p:cSld><p:clrMap %s/><p:sldLayoutIdLst>'
1382
+ '<p:sldLayoutId id="2147483649" r:id="rId1"/><p:sldLayoutId id="2147483650" r:id="rId2"/>'
1383
+ '</p:sldLayoutIdLst>%s</p:sldMaster>') % (NS, GRP, title, body, rule, CLRMAP, text_styles())
1384
+
1385
+
1386
+ def layout_xml(kind, width):
1387
+ bw = width - 2 * MARGIN_X
1388
+ if kind == 1:
1389
+ t = ph_sp(2, "Title 1", '<p:ph type="ctrTitle"/>', xfrm(MARGIN_X, int(1.9 * EMU_IN), bw, int(1.9 * EMU_IN)),
1390
+ '<a:bodyPr anchor="b"><a:normAutofit/></a:bodyPr>',
1391
+ '<a:lvl1pPr algn="ctr"><a:defRPr sz="4000"/></a:lvl1pPr>', "Click to edit Master title style")
1392
+ s = ph_sp(3, "Subtitle 2", '<p:ph type="subTitle" idx="1"/>',
1393
+ xfrm(MARGIN_X, int(4.0 * EMU_IN), bw, int(1.6 * EMU_IN)),
1394
+ '<a:bodyPr><a:normAutofit/></a:bodyPr>',
1395
+ '<a:lvl1pPr marL="0" indent="0" algn="ctr"><a:spcBef><a:spcPts val="600"/></a:spcBef><a:buNone/>'
1396
+ '<a:defRPr sz="2000"><a:solidFill><a:srgbClr val="595959"/></a:solidFill></a:defRPr></a:lvl1pPr>',
1397
+ "Click to edit Master subtitle style")
1398
+ return (XML_HEAD + '<p:sldLayout %s type="title" preserve="1" showMasterSp="0"><p:cSld name="Title Slide"><p:spTree>%s%s%s'
1399
+ '</p:spTree></p:cSld><p:clrMapOvr><a:masterClrMapping/></p:clrMapOvr></p:sldLayout>') % (NS, GRP, t, s)
1400
+ t = ph_sp(2, "Title 1", '<p:ph type="title"/>', xfrm(MARGIN_X, TITLE_Y, bw, TITLE_H),
1401
+ '<a:bodyPr/>', "", "Click to edit Master title style")
1402
+ b = ph_sp(3, "Content Placeholder 2", '<p:ph idx="1"/>', xfrm(MARGIN_X, BODY_Y, bw, BODY_BOTTOM - BODY_Y),
1403
+ '<a:bodyPr/>', "", "Click to edit Master text styles")
1404
+ return (XML_HEAD + '<p:sldLayout %s type="obj" preserve="1"><p:cSld name="Title and Content"><p:spTree>%s%s%s'
1405
+ '</p:spTree></p:cSld><p:clrMapOvr><a:masterClrMapping/></p:clrMapOvr></p:sldLayout>') % (NS, GRP, t, b)
1406
+
1407
+
1408
+ def notes_master_xml(width):
1409
+ img_h = int(5486400 * SLIDE_H / float(width))
1410
+ return (XML_HEAD + '<p:notesMaster %s><p:cSld><p:bg><p:bgRef idx="1001"><a:schemeClr val="bg1"/></p:bgRef>'
1411
+ '</p:bg><p:spTree>%s'
1412
+ '<p:sp><p:nvSpPr><p:cNvPr id="2" name="Slide Image Placeholder 1"/><p:cNvSpPr><a:spLocks noGrp="1" '
1413
+ 'noRot="1" noChangeAspect="1"/></p:cNvSpPr><p:nvPr><p:ph type="sldImg" idx="2"/></p:nvPr></p:nvSpPr>'
1414
+ '<p:spPr>%s<a:prstGeom prst="rect"><a:avLst/></a:prstGeom><a:noFill/><a:ln w="12700"><a:solidFill>'
1415
+ '<a:prstClr val="black"/></a:solidFill></a:ln></p:spPr></p:sp>'
1416
+ '<p:sp><p:nvSpPr><p:cNvPr id="3" name="Notes Placeholder 2"/><p:cNvSpPr><a:spLocks noGrp="1"/>'
1417
+ '</p:cNvSpPr><p:nvPr><p:ph type="body" sz="quarter" idx="3"/></p:nvPr></p:nvSpPr><p:spPr>%s'
1418
+ '<a:prstGeom prst="rect"><a:avLst/></a:prstGeom></p:spPr><p:txBody><a:bodyPr vert="horz" lIns="91440" '
1419
+ 'tIns="45720" rIns="91440" bIns="45720" rtlCol="0"/><a:lstStyle/><a:p><a:pPr lvl="0"/><a:r>'
1420
+ '<a:rPr lang="en-US"/><a:t>Click to edit Master text styles</a:t></a:r></a:p></p:txBody></p:sp>'
1421
+ '</p:spTree></p:cSld><p:clrMap %s/><p:notesStyle><a:lvl1pPr marL="0" algn="l" defTabSz="914400" '
1422
+ 'rtl="0" eaLnBrk="1" latinLnBrk="0" hangingPunct="1"><a:defRPr sz="1200" kern="1200"><a:solidFill>'
1423
+ '<a:schemeClr val="tx1"/></a:solidFill><a:latin typeface="+mn-lt"/><a:ea typeface="+mn-ea"/>'
1424
+ '<a:cs typeface="+mn-cs"/></a:defRPr></a:lvl1pPr></p:notesStyle></p:notesMaster>') % (
1425
+ NS, GRP, xfrm(685800, 1143000, 5486400, img_h), xfrm(685800, 4400550, 5486400, 3600450), CLRMAP)
1426
+
1427
+
1428
+ # =========================================================================== brand
1429
+
1430
+ def hex6(val):
1431
+ s = str(val).strip()
1432
+ if re.match(r"^#?[0-9A-Fa-f]{6}$", s):
1433
+ return s.lstrip("#").upper()
1434
+ return None
1435
+
1436
+
1437
+ def luminance(h):
1438
+ def ch(v):
1439
+ v = v / 255.0
1440
+ return v / 12.92 if v <= 0.03928 else ((v + 0.055) / 1.055) ** 2.4
1441
+ r, g, b = (int(h[i:i + 2], 16) for i in (0, 2, 4))
1442
+ return 0.2126 * ch(r) + 0.7152 * ch(g) + 0.0722 * ch(b)
1443
+
1444
+
1445
+ def contrast(a, b):
1446
+ la, lb = sorted((luminance(a), luminance(b)), reverse=True)
1447
+ return (la + 0.05) / (lb + 0.05)
1448
+
1449
+
1450
+ def load_brand(path):
1451
+ brand = {"heading_font": "Calibri", "body_font": "Calibri", "primary": "1F3864", "accent": "2E75B6",
1452
+ "text": "222222"}
1453
+ branded = False
1454
+ if path:
1455
+ try:
1456
+ with open(path, encoding="utf-8") as f:
1457
+ data = json.load(f)
1458
+ except (OSError, ValueError) as e:
1459
+ die("cannot read brand file %s: %s" % (path, e))
1460
+ if not isinstance(data, dict):
1461
+ die("brand file %s is not a JSON object" % path)
1462
+ branded = True
1463
+ fonts = data.get("fonts") if isinstance(data.get("fonts"), dict) else {}
1464
+ colors = data.get("colors") if isinstance(data.get("colors"), dict) else {}
1465
+ for key, field in (("heading_font", "heading"), ("body_font", "body")):
1466
+ val = fonts.get(field)
1467
+ fam = val.get("family") if isinstance(val, dict) else val if isinstance(val, str) else None
1468
+ if fam and str(fam).strip():
1469
+ brand[key] = str(fam).strip()
1470
+ for key in ("primary", "accent", "text"):
1471
+ val = colors.get(key)
1472
+ if val:
1473
+ h = hex6(val)
1474
+ if h:
1475
+ brand[key] = h
1476
+ else:
1477
+ warn(0, "brand colors.%s %r is not a 6-digit hex code; default #%s kept" % (key, val, brand[key]))
1478
+ if contrast(brand["primary"], "FFFFFF") < 3.0:
1479
+ warn(0, "brand primary #%s is too light for slide titles on white (contrast %.1f:1); "
1480
+ "titles use the text colour instead" % (brand["primary"], contrast(brand["primary"], "FFFFFF")))
1481
+ brand["primary_for_text"] = brand["text"]
1482
+ brand["on_primary"] = "FFFFFF" if contrast(brand["primary"], "FFFFFF") >= contrast(brand["primary"], "1F1F1F") \
1483
+ else "1F1F1F"
1484
+ return brand, branded
1485
+
1486
+
1487
+ # =========================================================================== verification
1488
+
1489
+ def verify(path):
1490
+ """Re-open the package: XML parses, internal targets exist, every part has a content type."""
1491
+ problems = []
1492
+ with zipfile.ZipFile(path) as z:
1493
+ names = set(z.namelist())
1494
+ xml_parts = [n for n in names if n.endswith(".xml") or n.endswith(".rels")]
1495
+ for n in xml_parts:
1496
+ try:
1497
+ minidom.parseString(z.read(n))
1498
+ except Exception as e: # noqa: BLE001 - report any parser failure
1499
+ problems.append("%s does not parse: %s" % (n, e))
1500
+ ct = minidom.parseString(z.read("[Content_Types].xml"))
1501
+ defaults = {e.getAttribute("Extension").lower() for e in ct.getElementsByTagName("Default")}
1502
+ overrides = {e.getAttribute("PartName") for e in ct.getElementsByTagName("Override")}
1503
+ for o in overrides:
1504
+ if o.lstrip("/") not in names:
1505
+ problems.append("content type declared for missing part %s" % o)
1506
+ for n in names:
1507
+ if n == "[Content_Types].xml" or n.endswith("/"):
1508
+ continue
1509
+ if "/" + n not in overrides and n.rsplit(".", 1)[-1].lower() not in defaults:
1510
+ problems.append("no content type for %s" % n)
1511
+ n_rels = 0
1512
+ for n in names:
1513
+ if not n.endswith(".rels"):
1514
+ continue
1515
+ folder = posixpath.dirname(posixpath.dirname(n))
1516
+ ids = set()
1517
+ for r in minidom.parseString(z.read(n)).getElementsByTagName("Relationship"):
1518
+ n_rels += 1
1519
+ rid = r.getAttribute("Id")
1520
+ if rid in ids:
1521
+ problems.append("%s repeats relationship id %s" % (n, rid))
1522
+ ids.add(rid)
1523
+ if r.getAttribute("TargetMode") == "External":
1524
+ continue
1525
+ t = r.getAttribute("Target")
1526
+ full = posixpath.normpath(t.lstrip("/") if t.startswith("/") else posixpath.join(folder, t))
1527
+ if full not in names:
1528
+ problems.append("%s: %s points to missing %s" % (n, rid, full))
1529
+ src = n.replace("_rels/", "").rsplit(".rels", 1)[0]
1530
+ if src in names and src.endswith(".xml"):
1531
+ used = set(re.findall(r'r:(?:id|embed)="([^"]+)"', z.read(src).decode("utf-8")))
1532
+ for rid in used - ids:
1533
+ problems.append("%s uses %s, which %s does not define" % (src, rid, n))
1534
+ if problems:
1535
+ for p in problems:
1536
+ print("ERROR verify: %s" % p, file=sys.stderr)
1537
+ die("the written file failed its own check; please report this with the input", code=2)
1538
+ return len(xml_parts), n_rels
1539
+
1540
+
1541
+ # =========================================================================== main
1542
+
1543
+ def front_matter(lines):
1544
+ fm, base = {}, 1
1545
+ if lines and lines[0].strip() == "---":
1546
+ for k in range(1, min(len(lines), 200)):
1547
+ if lines[k].strip() in ("---", "..."):
1548
+ for fl in lines[1:k]:
1549
+ m = re.match(r"^(title|subtitle|author|date|presenter)\s*:\s*[\"']?(.*?)[\"']?\s*$", fl, re.I)
1550
+ if m and m.group(2):
1551
+ fm[m.group(1).lower()] = m.group(2)
1552
+ used = ", ".join(k2 for k2 in ("title", "subtitle", "author", "presenter", "date") if k2 in fm)
1553
+ warn(1, "YAML front matter dropped (lines 1-%d)%s" % (
1554
+ k + 1, "; used on the title slide: %s" % used if used else ""))
1555
+ return fm, lines[k + 1:], k + 2
1556
+ return fm, lines, base
1557
+
1558
+
1559
+ def convert(src, out, brand_path, title_arg, aspect):
1560
+ if src == "-":
1561
+ text = sys.stdin.read()
1562
+ src_dir = os.getcwd()
1563
+ stem = "slides"
1564
+ else:
1565
+ try:
1566
+ with open(src, encoding="utf-8-sig") as f:
1567
+ text = f.read()
1568
+ except OSError as e:
1569
+ die("cannot read %s: %s" % (src, e))
1570
+ except UnicodeDecodeError:
1571
+ die("%s is not UTF-8 text; save it as UTF-8 and rerun" % src)
1572
+ src_dir = os.path.dirname(os.path.abspath(src))
1573
+ stem = os.path.splitext(os.path.basename(src))[0]
1574
+ if "\x00" in text:
1575
+ die("%s looks like a binary file, not Markdown" % src)
1576
+ if not text.strip():
1577
+ die("%s is empty" % src)
1578
+ out_dir = os.path.dirname(os.path.abspath(out))
1579
+ if not os.path.isdir(out_dir):
1580
+ die("output folder %s does not exist" % out_dir)
1581
+
1582
+ lines = text.replace("\r\n", "\n").replace("\r", "\n").split("\n")
1583
+ fm, lines, base = front_matter(lines)
1584
+ brand, branded = load_brand(brand_path)
1585
+ refs = {}
1586
+ blocks = parse_blocks(lines, base, refs)
1587
+ deck = Deck(src_dir, aspect, brand, refs)
1588
+
1589
+ # ---- title, subtitle, and the sections that become slides
1590
+ # the title slide shows what a reader of the Markdown sees: --title, else the first # heading
1591
+ # (when it is the first heading), else the front matter title
1592
+ title, title_line = title_arg, 0
1593
+ first_heading = next((b for b in blocks if b["type"] == "heading"), None)
1594
+ if first_heading is not None and first_heading["level"] == 1:
1595
+ if title:
1596
+ warn(first_heading["line"], "first # heading \"%s\" replaced on the title slide by --title"
1597
+ % first_heading["text"][:50])
1598
+ else:
1599
+ title, title_line = first_heading["text"], first_heading["line"]
1600
+ blocks.remove(first_heading)
1601
+ if not title:
1602
+ title = fm.get("title")
1603
+ pre, sections, cur = [], [], None
1604
+ for b in blocks:
1605
+ if b["type"] == "heading" and b["level"] <= 2:
1606
+ cur = {"title": b["text"], "line": b["line"], "level": b["level"], "blocks": []}
1607
+ sections.append(cur)
1608
+ elif cur is None:
1609
+ pre.append(b)
1610
+ else:
1611
+ cur["blocks"].append(b)
1612
+
1613
+ sub_lines = []
1614
+ for key in ("subtitle", "author", "presenter", "date"):
1615
+ if fm.get(key):
1616
+ sub_lines.append(fm[key])
1617
+ title_notes = [b["text"] for b in pre if b["type"] == "notes"]
1618
+ content_pre = [b for b in pre if b["type"] != "notes"]
1619
+ if (title and not sub_lines and len(content_pre) == 1 and content_pre[0]["type"] == "para"
1620
+ and len(" ".join(l for _, l in content_pre[0]["lines"])) <= 200):
1621
+ sub_lines.append("\n".join(l for _, l in content_pre[0]["lines"]).strip())
1622
+ content_pre = []
1623
+ if title:
1624
+ if content_pre:
1625
+ sections.insert(0, {"title": title, "line": title_line, "level": 2,
1626
+ "blocks": content_pre})
1627
+ else:
1628
+ if content_pre:
1629
+ sections.insert(0, {"title": stem, "line": 0, "level": 2, "blocks": content_pre})
1630
+ warn(0, "text before the first ## heading has no heading; its slide is titled with the file name "
1631
+ "\"%s\"" % stem)
1632
+ warn(0, "no # title, front matter title or --title; the deck has no title slide")
1633
+ title_notes = []
1634
+ if not sections and not title:
1635
+ die("%s has nothing to put on slides" % src)
1636
+
1637
+ # ---- paginate every section
1638
+ plan = [] # dicts: kind, title_segs, units, layout, section, page, pages
1639
+ if title:
1640
+ plan.append({"kind": "title", "title": title, "line": title_line, "units": [], "sub": sub_lines,
1641
+ "notes": title_notes})
1642
+ anchors = {}
1643
+ splits = []
1644
+ for s in sections:
1645
+ units = deck.units_from_blocks(s["blocks"])
1646
+ if s["level"] == 1 and not [u for u in units if u["kind"] not in ("notes", "break")]:
1647
+ anchors.setdefault(slugify(s["title"]), len(plan) + 1)
1648
+ plan.append({"kind": "section", "title": s["title"], "line": s["line"], "units": [], "sub": [],
1649
+ "notes": [u["text"] for u in units if u["kind"] == "notes"]})
1650
+ continue
1651
+ title_lines = text_lines(s["title"], TITLE_PT, deck.body_w - 2 * SIDE_INSET, deck.head_factor * 1.05)
1652
+ if title_lines > 2:
1653
+ warn(s["line"], "slide title \"%s\" wraps to %d lines; shorten it to fit two" % (
1654
+ s["title"][:50], title_lines))
1655
+ pages, reasons = deck.paginate(units, s["title"], s["line"])
1656
+ first = len(plan) + 1
1657
+ anchors.setdefault(slugify(s["title"]), first)
1658
+ for p_i, page in enumerate(pages):
1659
+ for u in page:
1660
+ if u.get("slug"):
1661
+ anchors.setdefault(u["slug"], len(plan) + 1)
1662
+ plan.append({"kind": "content", "title": s["title"] + (" (continued)" if p_i else ""),
1663
+ "line": s["line"], "units": page, "sub": [], "notes": []})
1664
+ if reasons:
1665
+ splits.append((s, first, first + len(pages) - 1, sorted(set(reasons))))
1666
+
1667
+ for s, a, b, why in splits:
1668
+ warn(s["line"], "\"%s\" did not fit on one slide; split into %d slides (%d-%d) at a %s boundary, "
1669
+ "text kept at full size" % (s["title"][:60], b - a + 1, a, b, " / ".join(why)))
1670
+
1671
+ # ---- render
1672
+ width = deck.width
1673
+ slides = []
1674
+ for n, item in enumerate(plan, 1):
1675
+ layout = 2 if item["kind"] == "content" else 1
1676
+ so = SlideOut(deck, layout, anchors, n)
1677
+ segs = deck.inline(item["title"], item["line"])
1678
+ segs = [s for s in segs if s[0] != "img"]
1679
+ if item["kind"] == "content":
1680
+ so.title(segs, item["line"])
1681
+ so.layout_body(item["units"])
1682
+ else:
1683
+ so.title(segs, item["line"], ctr=True,
1684
+ sub_lines=[deck.inline(t, item["line"]) for t in item["sub"]])
1685
+ so.notes = item["notes"] + so.notes
1686
+ slides.append(so)
1687
+
1688
+ # ---- package
1689
+ n_notes = sum(1 for s in slides if s.notes)
1690
+ now = datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
1691
+ pres_rels = [("rId1", "slideMaster", "slideMasters/slideMaster1.xml", False)]
1692
+ sld_ids = []
1693
+ for i in range(len(slides)):
1694
+ rid = "rId%d" % (len(pres_rels) + 1)
1695
+ pres_rels.append((rid, "slide", "slides/slide%d.xml" % (i + 1), False))
1696
+ sld_ids.append('<p:sldId id="%d" r:id="%s"/>' % (256 + i, rid))
1697
+ notes_master_id = ""
1698
+ if n_notes:
1699
+ rid = "rId%d" % (len(pres_rels) + 1)
1700
+ pres_rels.append((rid, "notesMaster", "notesMasters/notesMaster1.xml", False))
1701
+ notes_master_id = '<p:notesMasterIdLst><p:notesMasterId r:id="%s"/></p:notesMasterIdLst>' % rid
1702
+ for kind, target in (("theme", "theme/theme1.xml"), ("presProps", "presProps.xml"),
1703
+ ("viewProps", "viewProps.xml"), ("tableStyles", "tableStyles.xml")):
1704
+ pres_rels.append(("rId%d" % (len(pres_rels) + 1), kind, target, False))
1705
+ presentation = (XML_HEAD + '<p:presentation %s saveSubsetFonts="1"><p:sldMasterIdLst>'
1706
+ '<p:sldMasterId id="2147483648" r:id="rId1"/></p:sldMasterIdLst>%s<p:sldIdLst>%s</p:sldIdLst>'
1707
+ '<p:sldSz cx="%d" cy="%d"%s/><p:notesSz cx="6858000" cy="9144000"/>'
1708
+ '<p:defaultTextStyle><a:defPPr><a:defRPr lang="en-US"/></a:defPPr></p:defaultTextStyle>'
1709
+ '</p:presentation>') % (NS, notes_master_id, "".join(sld_ids), width, SLIDE_H,
1710
+ ' type="screen4x3"' if aspect == "4:3" else "")
1711
+ overrides = [("/ppt/presentation.xml", CT + "presentation.main+xml"),
1712
+ ("/ppt/slideMasters/slideMaster1.xml", CT + "slideMaster+xml"),
1713
+ ("/ppt/slideLayouts/slideLayout1.xml", CT + "slideLayout+xml"),
1714
+ ("/ppt/slideLayouts/slideLayout2.xml", CT + "slideLayout+xml"),
1715
+ ("/ppt/theme/theme1.xml", "application/vnd.openxmlformats-officedocument.theme+xml"),
1716
+ ("/ppt/presProps.xml", CT + "presProps+xml"),
1717
+ ("/ppt/viewProps.xml", CT + "viewProps+xml"),
1718
+ ("/ppt/tableStyles.xml", CT + "tableStyles+xml"),
1719
+ ("/docProps/core.xml", "application/vnd.openxmlformats-package.core-properties+xml"),
1720
+ ("/docProps/app.xml", "application/vnd.openxmlformats-officedocument.extended-properties+xml")]
1721
+ if n_notes:
1722
+ overrides.append(("/ppt/notesMasters/notesMaster1.xml", CT + "notesMaster+xml"))
1723
+ overrides.append(("/ppt/theme/theme2.xml", "application/vnd.openxmlformats-officedocument.theme+xml"))
1724
+ for i, s in enumerate(slides, 1):
1725
+ overrides.append(("/ppt/slides/slide%d.xml" % i, CT + "slide+xml"))
1726
+ if s.notes:
1727
+ overrides.append(("/ppt/notesSlides/notesSlide%d.xml" % i, CT + "notesSlide+xml"))
1728
+ content_types = (XML_HEAD + '<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">'
1729
+ '<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>'
1730
+ '<Default Extension="xml" ContentType="application/xml"/>'
1731
+ '<Default Extension="png" ContentType="image/png"/>'
1732
+ '<Default Extension="jpeg" ContentType="image/jpeg"/>%s</Types>') % "".join(
1733
+ '<Override PartName="%s" ContentType="%s"/>' % o for o in overrides)
1734
+ root_rels = rels_xml([("rId1", "officeDocument", "ppt/presentation.xml", False),
1735
+ ("rId2", "metadata/core-properties", "docProps/core.xml", False),
1736
+ ("rId3", "extended-properties", "docProps/app.xml", False)])
1737
+ root_rels = root_rels.replace(REL + "metadata/core-properties",
1738
+ "http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties")
1739
+ doc_title = fm.get("title") or title or stem
1740
+ core = (XML_HEAD + '<cp:coreProperties xmlns:cp="http://schemas.openxmlformats.org/package/2006/metadata/'
1741
+ 'core-properties" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:dcterms="http://purl.org/dc/terms/" '
1742
+ 'xmlns:dcmitype="http://purl.org/dc/dcmitype/" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">'
1743
+ '<dc:title>%s</dc:title><dc:creator></dc:creator>'
1744
+ '<dcterms:created xsi:type="dcterms:W3CDTF">%s</dcterms:created>'
1745
+ '<dcterms:modified xsi:type="dcterms:W3CDTF">%s</dcterms:modified></cp:coreProperties>') % (
1746
+ x(re.sub(r"[*_`]", "", doc_title)), now, now)
1747
+ app = (XML_HEAD + '<Properties xmlns="http://schemas.openxmlformats.org/officeDocument/2006/extended-properties">'
1748
+ '<Application>md_to_pptx.py</Application><Slides>%d</Slides><Notes>%d</Notes></Properties>') % (
1749
+ len(slides), n_notes)
1750
+ pres_props = XML_HEAD + '<p:presentationPr %s/>' % NS
1751
+ view_props = (XML_HEAD + '<p:viewPr %s><p:normalViewPr><p:restoredLeft sz="15620"/><p:restoredTop sz="94660"/>'
1752
+ '</p:normalViewPr><p:gridSpacing cx="76200" cy="76200"/></p:viewPr>') % NS
1753
+ table_styles = (XML_HEAD + '<a:tblStyleLst xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" '
1754
+ 'def="{2D5ABB26-0587-4C30-8999-92F81FD0307C}"/>')
1755
+ title_brand = dict(brand, primary=brand.get("primary_for_text", brand["primary"]))
1756
+
1757
+ with zipfile.ZipFile(out, "w", zipfile.ZIP_DEFLATED) as z:
1758
+ z.writestr("[Content_Types].xml", content_types)
1759
+ z.writestr("_rels/.rels", root_rels)
1760
+ z.writestr("docProps/core.xml", core)
1761
+ z.writestr("docProps/app.xml", app)
1762
+ z.writestr("ppt/presentation.xml", presentation)
1763
+ z.writestr("ppt/_rels/presentation.xml.rels", rels_xml(pres_rels))
1764
+ z.writestr("ppt/presProps.xml", pres_props)
1765
+ z.writestr("ppt/viewProps.xml", view_props)
1766
+ z.writestr("ppt/tableStyles.xml", table_styles)
1767
+ z.writestr("ppt/theme/theme1.xml", theme_xml(title_brand, "Deck"))
1768
+ z.writestr("ppt/slideMasters/slideMaster1.xml", master_xml(width))
1769
+ z.writestr("ppt/slideMasters/_rels/slideMaster1.xml.rels", rels_xml([
1770
+ ("rId1", "slideLayout", "../slideLayouts/slideLayout1.xml", False),
1771
+ ("rId2", "slideLayout", "../slideLayouts/slideLayout2.xml", False),
1772
+ ("rId3", "theme", "../theme/theme1.xml", False)]))
1773
+ for k in (1, 2):
1774
+ z.writestr("ppt/slideLayouts/slideLayout%d.xml" % k, layout_xml(k, width))
1775
+ z.writestr("ppt/slideLayouts/_rels/slideLayout%d.xml.rels" % k, rels_xml([
1776
+ ("rId1", "slideMaster", "../slideMasters/slideMaster1.xml", False)]))
1777
+ if n_notes:
1778
+ z.writestr("ppt/theme/theme2.xml", theme_xml(title_brand, "Deck notes"))
1779
+ z.writestr("ppt/notesMasters/notesMaster1.xml", notes_master_xml(width))
1780
+ z.writestr("ppt/notesMasters/_rels/notesMaster1.xml.rels", rels_xml([
1781
+ ("rId1", "theme", "../theme/theme2.xml", False)]))
1782
+ for i, s in enumerate(slides, 1):
1783
+ if s.notes:
1784
+ s.add_rel("notesSlide", "../notesSlides/notesSlide%d.xml" % i)
1785
+ z.writestr("ppt/notesSlides/notesSlide%d.xml" % i, notes_xml(s.notes, width))
1786
+ z.writestr("ppt/notesSlides/_rels/notesSlide%d.xml.rels" % i, rels_xml([
1787
+ ("rId1", "notesMaster", "../notesMasters/notesMaster1.xml", False),
1788
+ ("rId2", "slide", "../slides/slide%d.xml" % i, False)]))
1789
+ z.writestr("ppt/slides/slide%d.xml" % i, s.xml())
1790
+ z.writestr("ppt/slides/_rels/slide%d.xml.rels" % i, rels_xml(s.rels))
1791
+ for name, data in deck.media:
1792
+ z.writestr(name, data)
1793
+
1794
+ n_parts, n_rels = verify(out)
1795
+
1796
+ # ---- report
1797
+ c = deck.counts
1798
+ n_cont = sum(1 for p in plan if p["title"].endswith(" (continued)") and p["kind"] == "content")
1799
+ parts = ["%d slide%s" % (len(slides), "" if len(slides) == 1 else "s")]
1800
+ if n_cont:
1801
+ parts.append("%d of them continued" % n_cont)
1802
+ for k in ("tables", "images", "code blocks", "links"):
1803
+ if c[k]:
1804
+ parts.append("%d %s" % (c[k], k[:-1] if c[k] == 1 else k))
1805
+ parts.append("speaker notes on %d" % n_notes)
1806
+ print("wrote %s (%s; %s; brand: %s)" % (out, ", ".join(parts), aspect,
1807
+ "%s / %s" % (brand["heading_font"], brand["body_font"]) if branded else "no"))
1808
+ print(" checked: %d XML parts parse, %d relationships resolve, every part has a content type" % (
1809
+ n_parts, n_rels))
1810
+ for n, item in enumerate(plan, 1):
1811
+ bits = []
1812
+ for k in ("table", "image", "code"):
1813
+ m = sum(1 for u in item["units"] if u["kind"] == k)
1814
+ if m:
1815
+ bits.append("%s%s" % (k, " x%d" % m if m > 1 else ""))
1816
+ if slides[n - 1].notes:
1817
+ bits.append("notes")
1818
+ label = {"title": "title", "section": "section"}.get(item["kind"], "")
1819
+ print(" %2d. %s%s%s" % (n, re.sub(r"[*_`]", "", item["title"]),
1820
+ " [%s]" % label if label else "", " [%s]" % ", ".join(bits) if bits else ""))
1821
+ if WARNINGS:
1822
+ print("%d warning(s): the items above were dropped, split or changed; tell the user before sharing"
1823
+ % len(WARNINGS))
1824
+
1825
+
1826
+ def main(argv):
1827
+ ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
1828
+ ap.add_argument("input", help="Markdown file, or - for stdin")
1829
+ ap.add_argument("-o", "--output", required=True, help="output .pptx path")
1830
+ ap.add_argument("--brand", help="brand.json (fonts.heading/body, colors.primary/accent/text)")
1831
+ ap.add_argument("--title", help="title slide text (default: front matter title, then the first # heading)")
1832
+ ap.add_argument("--aspect", choices=sorted(SLIDE_W), default="16:9", help="slide shape (default 16:9)")
1833
+ a = ap.parse_args(argv)
1834
+ if not a.output.lower().endswith(".pptx"):
1835
+ die("output must end in .pptx: %s" % a.output)
1836
+ convert(a.input, a.output, a.brand, a.title, a.aspect)
1837
+ return 0
1838
+
1839
+
1840
+ if __name__ == "__main__":
1841
+ sys.exit(main(sys.argv[1:]))