@litfamily/litclaude 1.0.19 → 1.0.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- package/README.md +14 -8
- package/README_ko-KR.md +13 -8
- package/docs/assets/readme/badge-version.svg +1 -1
- package/docs/hooks.md +3 -2
- package/package.json +1 -1
- package/plugins/litclaude/.claude-plugin/plugin.json +1 -1
- package/plugins/litclaude/bin/litclaude-hook.js +4 -4
- package/plugins/litclaude/bin/litclaude-mcp.js +1 -1
- package/plugins/litclaude/lib/canonical-skill-resources.mjs +91 -31
- package/plugins/litclaude/skills/frontend-ui-ux/ATTRIBUTION.md +3 -6
- package/plugins/litclaude/skills/lit-docx/SKILL.md +50 -19
- package/plugins/litclaude/skills/lit-docx/references/components.md +207 -0
- package/plugins/litclaude/skills/lit-docx/references/direction-step.md +277 -0
- package/plugins/litclaude/skills/lit-docx/references/examples/01-report-ko.md +166 -0
- package/plugins/litclaude/skills/lit-docx/references/examples/02-brief-ko.md +105 -0
- package/plugins/litclaude/skills/lit-docx/references/examples/03-manual-ko.md +164 -0
- package/plugins/litclaude/skills/lit-docx/references/examples/04-proposal-ko.md +138 -0
- package/plugins/litclaude/skills/lit-docx/references/examples/05-memo-en.md +55 -0
- package/plugins/litclaude/skills/lit-docx/references/examples/06-journal-en.md +130 -0
- package/plugins/litclaude/skills/lit-docx/references/examples/07-report-en.md +123 -0
- package/plugins/litclaude/skills/lit-docx/references/examples/08-proposal-en.md +123 -0
- package/plugins/litclaude/skills/lit-docx/references/examples/09-memo-ko.md +45 -0
- package/plugins/litclaude/skills/lit-docx/references/page-composition.md +221 -0
- package/plugins/litclaude/skills/lit-docx/references/tonalities/brief.md +143 -0
- package/plugins/litclaude/skills/lit-docx/references/tonalities/journal.md +142 -0
- package/plugins/litclaude/skills/lit-docx/references/tonalities/manual.md +148 -0
- package/plugins/litclaude/skills/lit-docx/references/tonalities/memo.md +137 -0
- package/plugins/litclaude/skills/lit-docx/references/tonalities/proposal.md +140 -0
- package/plugins/litclaude/skills/lit-docx/references/tonalities/report.md +156 -0
- package/plugins/litclaude/skills/lit-docx/scripts/convert_md_to_docx.py +274 -73
- package/plugins/litclaude/skills/lit-docx/scripts/docx_design.py +2449 -0
- package/plugins/litclaude/skills/lit-docx/scripts/docx_layout.py +959 -0
- package/plugins/litclaude/skills/lit-docx/scripts/qa_docx.py +66 -9
- package/plugins/litclaude/skills/lit-docx/scripts/slop_lint.py +18 -6
- package/plugins/litclaude/skills/lit-docx/templates/tonalities/brief.yaml +28 -0
- package/plugins/litclaude/skills/lit-docx/templates/tonalities/journal.yaml +25 -0
- package/plugins/litclaude/skills/lit-docx/templates/tonalities/manual.yaml +26 -0
- package/plugins/litclaude/skills/lit-docx/templates/tonalities/memo.yaml +26 -0
- package/plugins/litclaude/skills/lit-docx/templates/tonalities/proposal.yaml +26 -0
- package/plugins/litclaude/skills/lit-docx/templates/tonalities/report.yaml +26 -0
- package/plugins/litclaude/skills/lit-pptx/SKILL.md +105 -48
- package/plugins/litclaude/skills/lit-pptx/references/agents/analyst.md +1 -1
- package/plugins/litclaude/skills/lit-pptx/references/agents/thesis.md +1 -1
- package/plugins/litclaude/skills/lit-pptx/references/anti-slop-checklist.md +59 -30
- package/plugins/litclaude/skills/lit-pptx/references/authoring-guide.md +119 -42
- package/plugins/litclaude/skills/lit-pptx/references/consensus-protocol.md +1 -1
- package/plugins/litclaude/skills/lit-pptx/references/data-display-cookbook.md +13 -6
- package/plugins/litclaude/skills/lit-pptx/references/density-and-fill.md +164 -0
- package/plugins/litclaude/skills/lit-pptx/references/design-system-azure.md +7 -4
- package/plugins/litclaude/skills/lit-pptx/references/direction-step.md +186 -0
- package/plugins/litclaude/skills/lit-pptx/references/examples/01-pitch-signal-ko.md +210 -0
- package/plugins/litclaude/skills/lit-pptx/references/examples/02-pitch-studio-ko.md +147 -0
- package/plugins/litclaude/skills/lit-pptx/references/examples/03-business-review-ledger-ko.md +199 -0
- package/plugins/litclaude/skills/lit-pptx/references/examples/04-business-review-night-en.md +203 -0
- package/plugins/litclaude/skills/lit-pptx/references/examples/05-research-talk-paper-en.md +179 -0
- package/plugins/litclaude/skills/lit-pptx/references/examples/06-lecture-chalk-ko.md +199 -0
- package/plugins/litclaude/skills/lit-pptx/references/examples/07-data-review-night-en.md +194 -0
- package/plugins/litclaude/skills/lit-pptx/references/examples/08-image-led-atlas-ko.md +175 -0
- package/plugins/litclaude/skills/lit-pptx/references/examples/09-briefing-gazette-ko.md +204 -0
- package/plugins/litclaude/skills/lit-pptx/references/examples/10-status-update-ledger-ko.md +179 -0
- package/plugins/litclaude/skills/lit-pptx/references/examples/11-portfolio-studio-en.md +164 -0
- package/plugins/litclaude/skills/lit-pptx/references/examples/12-research-tutorial-chalk-en.md +174 -0
- package/plugins/litclaude/skills/lit-pptx/references/interview-protocol.md +1 -1
- package/plugins/litclaude/skills/lit-pptx/references/layout-families.md +198 -0
- package/plugins/litclaude/skills/lit-pptx/references/stages/autopilot.md +1 -1
- package/plugins/litclaude/skills/lit-pptx/references/stages/interview.md +2 -2
- package/plugins/litclaude/skills/lit-pptx/references/stages/plan.md +1 -1
- package/plugins/litclaude/skills/lit-pptx/references/title-treatments.md +109 -0
- package/plugins/litclaude/skills/lit-pptx/references/tonalities/atlas.md +139 -0
- package/plugins/litclaude/skills/lit-pptx/references/tonalities/chalk.md +148 -0
- package/plugins/litclaude/skills/lit-pptx/references/tonalities/gazette.md +149 -0
- package/plugins/litclaude/skills/lit-pptx/references/tonalities/ledger.md +149 -0
- package/plugins/litclaude/skills/lit-pptx/references/tonalities/night.md +147 -0
- package/plugins/litclaude/skills/lit-pptx/references/tonalities/paper.md +151 -0
- package/plugins/litclaude/skills/lit-pptx/references/tonalities/signal.md +150 -0
- package/plugins/litclaude/skills/lit-pptx/references/tonalities/studio.md +132 -0
- package/plugins/litclaude/skills/lit-pptx/scripts/compile-deck.js +64 -16
- package/plugins/litclaude/skills/lit-pptx/scripts/craft_extras.py +664 -3
- package/plugins/litclaude/skills/lit-pptx/scripts/deck_craft.py +48 -6
- package/plugins/litclaude/skills/lit-pptx/scripts/lib/grid-resolver.js +521 -0
- package/plugins/litclaude/skills/lit-pptx/scripts/lib/layout-resolver.js +362 -11
- package/plugins/litclaude/skills/lit-pptx/scripts/lib/markdown-loader.js +6 -4
- package/plugins/litclaude/skills/lit-pptx/scripts/lib/render-adapter-pptx.js +211 -65
- package/plugins/litclaude/skills/lit-pptx/scripts/lib/render-pack.js +3559 -0
- package/plugins/litclaude/skills/lit-pptx/scripts/lib/slide-ast.js +10 -6
- package/plugins/litclaude/skills/lit-pptx/scripts/lib/template-registry.js +286 -1
- package/plugins/litclaude/skills/lit-pptx/scripts/qa_deck.py +39 -10
- package/plugins/litclaude/skills/lit-pptx/scripts/validate_pptx.py +22 -7
- package/plugins/litclaude/skills/lit-pptx/templates/enrolled/AZURE-A2Z/layout-mapping.yaml +1 -1
- package/plugins/litclaude/skills/lit-pptx/templates/enrolled/AZURE-A2Z/template.yaml +4 -1
- package/plugins/litclaude/skills/lit-pptx/templates/enrolled/AZURE-PRO/layout-mapping.yaml +1 -1
- package/plugins/litclaude/skills/lit-pptx/templates/enrolled/AZURE-PRO/template.yaml +4 -1
- package/plugins/litclaude/skills/lit-pptx/templates/tonalities/atlas/pack.yaml +92 -0
- package/plugins/litclaude/skills/lit-pptx/templates/tonalities/chalk/pack.yaml +97 -0
- package/plugins/litclaude/skills/lit-pptx/templates/tonalities/gazette/pack.yaml +93 -0
- package/plugins/litclaude/skills/lit-pptx/templates/tonalities/ledger/pack.yaml +94 -0
- package/plugins/litclaude/skills/lit-pptx/templates/tonalities/night/pack.yaml +93 -0
- package/plugins/litclaude/skills/lit-pptx/templates/tonalities/paper/pack.yaml +98 -0
- package/plugins/litclaude/skills/lit-pptx/templates/tonalities/signal/pack.yaml +94 -0
- package/plugins/litclaude/skills/lit-pptx/templates/tonalities/studio/pack.yaml +92 -0
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
# Report
|
|
2
|
+
|
|
3
|
+
Report is the working document of an organisation: a periodic result, a project report, an analysis
|
|
4
|
+
that a manager reads at a desk and comes back to. In Korean its default manner is institute prose, the
|
|
5
|
+
way a central-bank or think-tank issue note reads: full paragraphs that argue from evidence, numbered
|
|
6
|
+
sections, and tables that carry the numbers. The page is quiet on purpose. Every heading is set in the
|
|
7
|
+
same near-black ink as the body and stands out only by weight and the space above it; one navy rule
|
|
8
|
+
under the title block and the two rules of the decision callout are the only colour in the document.
|
|
9
|
+
What makes a report look finished here is its structure (a clear first page, honest tables, numbered
|
|
10
|
+
parts a reader can cite), never ornament.
|
|
11
|
+
|
|
12
|
+
## When to use it
|
|
13
|
+
|
|
14
|
+
Choose Report for results reports (분기 실적, 사업 결과), project close-outs, analyses with tables and
|
|
15
|
+
figures, audit and review reports, research notes for management, and any document of four to thirty
|
|
16
|
+
pages that is read section by section. It is the first candidate for the report type in the direction
|
|
17
|
+
table, and the one to move up when tables are more than a third of the blocks or the source holds four
|
|
18
|
+
or more tables. A Korean report and an English one use the same pack; the numbering style, captions and
|
|
19
|
+
leading follow the language.
|
|
20
|
+
|
|
21
|
+
## When it is the wrong choice
|
|
22
|
+
|
|
23
|
+
- A one-to-three page decision paper for an executive who reads the conclusion and stops: Brief puts
|
|
24
|
+
the decision request first and sets the summary as numbered points.
|
|
25
|
+
- A memo, notice or letter under two pages: the title block and the numbered parts are too heavy; use
|
|
26
|
+
Memo.
|
|
27
|
+
- A step-by-step procedure with warnings and a document-control block: Manual.
|
|
28
|
+
- Long continuous reading with few tables (an essay, a white paper): Journal's two columns hold the
|
|
29
|
+
measure.
|
|
30
|
+
- A government itemised briefing written in 개조식 lines (□ ○ - with ~함/~임 endings): that is not a
|
|
31
|
+
built-in Report variant. For an itemised briefing use Brief; write 개조식 lines inside a Report only
|
|
32
|
+
when the user asks for them.
|
|
33
|
+
- A journal submission: use the named publisher profile, never a tonality.
|
|
34
|
+
|
|
35
|
+
## Structure
|
|
36
|
+
|
|
37
|
+
The structure is what tells Report apart from the other five packs; two tonalities rendered from one
|
|
38
|
+
source must differ in at least three of these features, and the gate checks it with
|
|
39
|
+
`qa_docx.py a.docx --compare b.docx` (`tonality.structure`).
|
|
40
|
+
|
|
41
|
+
- **Page 1: a title block above the body, no cover page.** One line holds the series or kind label on
|
|
42
|
+
the left (the `kicker=` of `::: cover`, for example `현안 분석` or `Issue note`) and the Korean date
|
|
43
|
+
on the right (`2026. 6. 30.`, converted from the frontmatter date). Under it come the title, the
|
|
44
|
+
subtitle in muted ink, the author and organisation line, one navy rule, and the frontmatter
|
|
45
|
+
`notice:` once in small muted text. The summary starts on the same page. `variant=` on the cover is
|
|
46
|
+
accepted and ignored: every Report sets this typographic block.
|
|
47
|
+
- **Contents.** When the source runs to about five pages or more, a short contents list (each h1, a
|
|
48
|
+
dotted leader, its page) stands between the title block and the summary. Shorter reports have none.
|
|
49
|
+
- **Summary.** `# 요약` (or `# Summary`) as running prose: the first paragraph states the result in two
|
|
50
|
+
to four sentences. The single key-figure strip, if the report has one, is set right after that first
|
|
51
|
+
paragraph; key figures written inside `::: cover` are moved there automatically.
|
|
52
|
+
- **Numbering.** Korean sections are numbered `Ⅰ.` (h1), `1.` (h2), `가.` (h3) in plain ink at heading
|
|
53
|
+
size; English sections `1` and `1.1`. Write headings without numbers; the engine adds them.
|
|
54
|
+
- **Components and the purpose each serves.** Report allows three kinds: `keyfigures` (one strip of
|
|
55
|
+
three or four comparable metrics in the summary, so the reader has the scale before the argument),
|
|
56
|
+
`callout` (the decision request, then a warning, then a note, at about one per four pages), and
|
|
57
|
+
`columns` (two options of nearly equal length side by side). The direction card names each one with
|
|
58
|
+
a one-line purpose; a component without a purpose is left out.
|
|
59
|
+
- **Evidence.** Booktabs tables with `<표 n>` captions, a `(단위: …)` line, then `주:` and `자료:` lines
|
|
60
|
+
under the table. A part usually holds a table, the sentence that reads it, and a short analysis.
|
|
61
|
+
- **Running head.** Footer only, from page 2: the short title on the left, the folio on the right,
|
|
62
|
+
both small and muted. No section name in the running head, no notice in the header.
|
|
63
|
+
- **Close.** A `# 향후 과제` / `# Next steps` part with a table of owner, action and date when the report
|
|
64
|
+
asks for follow-up.
|
|
65
|
+
|
|
66
|
+
## Tokens
|
|
67
|
+
|
|
68
|
+
Values at the pack default (density 9, variance 5), read from `templates/tonalities/report.yaml`.
|
|
69
|
+
|
|
70
|
+
| Token | Value |
|
|
71
|
+
|---|---|
|
|
72
|
+
| Paper, margins | A4; top 25, bottom 27, left 25, right 25 mm; text block 160 mm |
|
|
73
|
+
| Body | Pretendard 10.5 pt; Korean justified, English ragged right |
|
|
74
|
+
| Line pitch | Hangul 175 % of the size (Word multiple 1.14), Latin 133 % (0.87); a paragraph holding a picture is single spaced |
|
|
75
|
+
| Paragraph spacing | 6 pt after (5 pt English), no first-line indent |
|
|
76
|
+
| Scale | h1 14.5 pt (1.4x), h2 12.5 pt (1.2x), h3 10.5 pt bold; title 26.5 pt (2.5x) |
|
|
77
|
+
| Heading spacing | h1 22 pt above, 9 pt below; h2 15 / 6 pt; no rule under any heading |
|
|
78
|
+
| Ink | body and headings `#1A1A1A`; muted `#555555` (subtitle, captions, notes, running heads); rules `#8C8C8C` |
|
|
79
|
+
| Accent | navy `1F3A5F`, on two element kinds only: the title rule and the callout rule |
|
|
80
|
+
| Tables | booktabs, 9.5 pt at 140 % pitch, header bold, numbers right-aligned |
|
|
81
|
+
| Key figure | h2 size, bold, in ink, over an ink hairline; label 9.5 pt, basis muted |
|
|
82
|
+
| Running head | footer: short title left, folio right, 8.5 pt muted |
|
|
83
|
+
| Components | keyfigures, callout, columns (three kinds at most) |
|
|
84
|
+
| Figures | at most 0.45 of the text-frame height |
|
|
85
|
+
| Fill | no body page under 0.35 (`fill.page`) |
|
|
86
|
+
|
|
87
|
+
**Dials.** Density moves margins and leading together: density 10 closes the margins to 24/26/25/25 mm
|
|
88
|
+
for a report a page over its limit; density 7 opens them to 27/29/27/27 mm with a Hangul pitch of 180 %.
|
|
89
|
+
Body type never drops below 10.5 pt. Variance is recorded on the card but no longer widens the
|
|
90
|
+
component set or the table styles; the budget above is fixed.
|
|
91
|
+
|
|
92
|
+
## Worked source
|
|
93
|
+
|
|
94
|
+
```markdown
|
|
95
|
+
::: cover kicker="현안 분석"
|
|
96
|
+
:::
|
|
97
|
+
|
|
98
|
+
# 요약
|
|
99
|
+
|
|
100
|
+
2026년 2분기 출고 처리량은 전년 동기보다 12% 늘었고, 증가분의 대부분은 북부 창고 야간 교대에서 나왔다.
|
|
101
|
+
|
|
102
|
+
::: keyfigures cols=3
|
|
103
|
+
- **+12%** 출고 처리량 (예시)
|
|
104
|
+
- 2026년 2분기, 전년 동기 대비, 운영 기록
|
|
105
|
+
- **3.1억 원** 2분기 운영비 (예시)
|
|
106
|
+
- 2026년 4-6월 합계, 내부 결산
|
|
107
|
+
- **96.4%** 당일 출고율 (예시)
|
|
108
|
+
- 2026년 6월, 창고 관리 시스템
|
|
109
|
+
:::
|
|
110
|
+
|
|
111
|
+
::: callout kind=key title="요청 사항"
|
|
112
|
+
북부 창고 야간 교대 1개 조 추가 여부를 7월 운영 회의에서 결정해 주시기 바랍니다.
|
|
113
|
+
:::
|
|
114
|
+
|
|
115
|
+
# 창고별 처리량과 비용
|
|
116
|
+
|
|
117
|
+
표 1. 창고별 분기 처리량 (예시)
|
|
118
|
+
|
|
119
|
+
| 창고 | 1분기 (천 건) | 2분기 (천 건) |
|
|
120
|
+
|---|---|---|
|
|
121
|
+
| 북부 | 412 | 486 |
|
|
122
|
+
| 남부 | 388 | 401 |
|
|
123
|
+
| 합계 | 800 | 887 |
|
|
124
|
+
|
|
125
|
+
자료: 창고 관리 시스템 (예시)
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
The caption becomes `<표 1> 창고별 분기 처리량 (예시)` with the label bold, the shared unit leaves the
|
|
129
|
+
headers for a right-aligned `(단위: 천 건)` line, and the 합계 row is set bold under a thin rule.
|
|
130
|
+
|
|
131
|
+
## Headings
|
|
132
|
+
|
|
133
|
+
| Use | Not |
|
|
134
|
+
|---|---|
|
|
135
|
+
| 2분기 처리량과 비용 | 2분기 처리량이 12% 늘었다 |
|
|
136
|
+
| 창고별 지연 원인 | 지연은 대부분 북부 창고에서 발생함 |
|
|
137
|
+
| Cost per order by site | Costs fell at every site |
|
|
138
|
+
|
|
139
|
+
The title and subtitle follow the same rule. `heading.declarative` fails a heading, title or subtitle
|
|
140
|
+
written as a sentence; the claim goes in the first sentence under the heading.
|
|
141
|
+
|
|
142
|
+
## Numbers
|
|
143
|
+
|
|
144
|
+
A key figure is set at the h2 size and never larger. Every figure carries a label and a basis line
|
|
145
|
+
(period, scope, source) and is rounded honestly; example data says (예시) or (sample) in the label. A
|
|
146
|
+
figure without a basis goes into a sentence. Data that needs more than four figures belongs in a table.
|
|
147
|
+
|
|
148
|
+
## Failure patterns
|
|
149
|
+
|
|
150
|
+
- A title block, a three-line summary, then a page holding one table: the summary should hold the
|
|
151
|
+
first paragraph, the strip and the request (`fill.page`).
|
|
152
|
+
- A second key-figure strip lower in the report: only the first is drawn; the rest print as a list.
|
|
153
|
+
- Callouts used to decorate paragraphs: only about one per four pages is drawn as a box, decision
|
|
154
|
+
request first; the rest keep their content as text with a bold lead line.
|
|
155
|
+
- Headings typed with their own numbers (`Ⅰ. 개요`), which doubles the numbering.
|
|
156
|
+
- Shrinking body type to meet a page limit; raise density one step instead.
|
|
@@ -17,6 +17,11 @@ Features:
|
|
|
17
17
|
journal design discipline: title block from YAML frontmatter, curly
|
|
18
18
|
quotes, en/em dashes, nbsp before units, ellipsis, heading auto-numbering,
|
|
19
19
|
booktabs tables, banned-font/style stripping, CJK font pairing.
|
|
20
|
+
- --tonality NAME (or frontmatter `tonality:`) builds the document in one of
|
|
21
|
+
the six design directions of templates/tonalities/ (docx_design.py), with
|
|
22
|
+
the page components written as ::: directives; --density and --variance
|
|
23
|
+
move the pack's dials. Without a tonality or a publisher the plain profile
|
|
24
|
+
runs through the same builders with neutral tokens.
|
|
20
25
|
"""
|
|
21
26
|
|
|
22
27
|
from __future__ import annotations
|
|
@@ -31,6 +36,7 @@ from office_data import DataError, resolve_text # noqa: E402
|
|
|
31
36
|
ensure_runtime(["docx", "markdown", "bs4", "yaml"])
|
|
32
37
|
|
|
33
38
|
import argparse
|
|
39
|
+
import copy
|
|
34
40
|
import re
|
|
35
41
|
import sys
|
|
36
42
|
from pathlib import Path
|
|
@@ -51,6 +57,7 @@ try:
|
|
|
51
57
|
from docx.enum.style import WD_STYLE_TYPE
|
|
52
58
|
from docx.oxml.ns import qn
|
|
53
59
|
from docx.oxml import OxmlElement
|
|
60
|
+
from docx.text.paragraph import Paragraph
|
|
54
61
|
except ImportError:
|
|
55
62
|
print("Error: 'python-docx' library not found. Install with: pip install python-docx")
|
|
56
63
|
sys.exit(1)
|
|
@@ -61,6 +68,8 @@ except ImportError:
|
|
|
61
68
|
print("Error: 'beautifulsoup4' library not found. Install with: pip install beautifulsoup4")
|
|
62
69
|
sys.exit(1)
|
|
63
70
|
|
|
71
|
+
import docx_design # noqa: E402 (needs the runtime's python-docx and PyYAML)
|
|
72
|
+
|
|
64
73
|
|
|
65
74
|
# ===========================================================================
|
|
66
75
|
# Legacy HTML→DOCX helpers (unchanged from pre-M1 — preserved byte-for-byte)
|
|
@@ -163,10 +172,16 @@ def process_list(doc: Document, element, md_path: Path, ordered: bool = False, l
|
|
|
163
172
|
if level > 0:
|
|
164
173
|
para.paragraph_format.left_indent = Inches(0.5 * level)
|
|
165
174
|
|
|
166
|
-
# Process direct text content
|
|
167
|
-
|
|
175
|
+
# Process direct text content. Only the item's own edges are trimmed: the space after a bold lead
|
|
176
|
+
# ("**1,200개** 태그") belongs to the text.
|
|
177
|
+
children = list(li.children)
|
|
178
|
+
for ci, child in enumerate(children):
|
|
168
179
|
if isinstance(child, NavigableString):
|
|
169
|
-
text = str(child).
|
|
180
|
+
text = str(child).replace("\n", " ")
|
|
181
|
+
if ci == 0 or getattr(children[ci - 1], "name", None) in ('ul', 'ol', 'p'):
|
|
182
|
+
text = text.lstrip()
|
|
183
|
+
if ci == len(children) - 1 or getattr(children[ci + 1], "name", None) in ('ul', 'ol', 'p'):
|
|
184
|
+
text = text.rstrip()
|
|
170
185
|
if text:
|
|
171
186
|
para.add_run(text)
|
|
172
187
|
elif child.name in ('ul', 'ol'):
|
|
@@ -299,6 +314,26 @@ def process_code_block(doc: Document, element):
|
|
|
299
314
|
para._p.get_or_add_pPr().append(shading)
|
|
300
315
|
|
|
301
316
|
|
|
317
|
+
def add_figure_caption(doc, element, para) -> None:
|
|
318
|
+
"""An image alone in its paragraph keeps its alt text as the caption under it
|
|
319
|
+
("Figure 1." / "그림 1." bold), and the image keeps with the caption."""
|
|
320
|
+
images = element.find_all('img')
|
|
321
|
+
alt = images[0].get('alt', '').strip() if len(images) == 1 else ''
|
|
322
|
+
if not alt or element.get_text(strip=True):
|
|
323
|
+
return
|
|
324
|
+
para.paragraph_format.keep_with_next = True
|
|
325
|
+
try:
|
|
326
|
+
caption = doc.add_paragraph(style='Caption')
|
|
327
|
+
except KeyError:
|
|
328
|
+
caption = doc.add_paragraph()
|
|
329
|
+
label = docx_design.FIGURE_LABEL.match(alt)
|
|
330
|
+
if label:
|
|
331
|
+
caption.add_run(label.group(1)).bold = True
|
|
332
|
+
caption.add_run(alt[len(label.group(1)):])
|
|
333
|
+
else:
|
|
334
|
+
caption.add_run(alt)
|
|
335
|
+
|
|
336
|
+
|
|
302
337
|
def html_to_docx(html: str, doc: Document, md_path: Path):
|
|
303
338
|
"""Convert HTML to DOCX elements."""
|
|
304
339
|
soup = BeautifulSoup(html, 'html.parser')
|
|
@@ -317,7 +352,8 @@ def html_to_docx(html: str, doc: Document, md_path: Path):
|
|
|
317
352
|
process_inline_elements(heading, child, md_path)
|
|
318
353
|
|
|
319
354
|
elif element.name == 'p':
|
|
320
|
-
add_paragraph_with_formatting(doc, element, md_path)
|
|
355
|
+
para = add_paragraph_with_formatting(doc, element, md_path)
|
|
356
|
+
add_figure_caption(doc, element, para)
|
|
321
357
|
|
|
322
358
|
elif element.name == 'ul':
|
|
323
359
|
process_list(doc, element, md_path, ordered=False)
|
|
@@ -690,6 +726,112 @@ def apply_table_style(doc, design: dict) -> None:
|
|
|
690
726
|
existing_bottom.set(qn("w:color"), "000000")
|
|
691
727
|
|
|
692
728
|
|
|
729
|
+
def keep_tables_with_captions(doc) -> None:
|
|
730
|
+
"""A table caption keeps with its table, and a table that fits one page keeps its rows together
|
|
731
|
+
(every row but the last keeps with the next). Pagination only: text, styles and rules are unchanged."""
|
|
732
|
+
section = doc.sections[-1]
|
|
733
|
+
frame_h = (section.page_height - section.top_margin - section.bottom_margin) / 12700
|
|
734
|
+
for table in doc.tables:
|
|
735
|
+
prev = table._tbl.getprevious()
|
|
736
|
+
if prev is not None and prev.tag == qn("w:p") and docx_design.TABLE_CAPTION.match("".join(t.text or "" for t in prev.iter(qn("w:t"))).strip()):
|
|
737
|
+
Paragraph(prev, table._parent).paragraph_format.keep_with_next = True
|
|
738
|
+
widths = [c.width / 12700 if c.width else 100 for c in table.rows[0].cells]
|
|
739
|
+
est = sum(6 + 11 * 1.3 * max(docx_design._est_lines(c.text, (widths[i] if i < len(widths) else 100) - 12, 11) for i, c in enumerate(row.cells)) for row in table.rows)
|
|
740
|
+
if est <= 0.9 * frame_h:
|
|
741
|
+
for row in table.rows[:-1]:
|
|
742
|
+
for cell in row.cells:
|
|
743
|
+
cell.paragraphs[0].paragraph_format.keep_with_next = True
|
|
744
|
+
|
|
745
|
+
|
|
746
|
+
def publisher_spacing(doc) -> None:
|
|
747
|
+
"""Text never touches a table or a caption (spec-v2 Amendments 7, R10): the paragraph after a table stands three
|
|
748
|
+
quarters of a body line clear (docx-spec Amendments 6; 8 pt read as half a line), a table caption after text
|
|
749
|
+
8 pt, and a figure caption keeps 8 pt under it. A paragraph after a list stands 6 pt clear of its last item."""
|
|
750
|
+
body = doc.element.body
|
|
751
|
+
normal = doc.styles["Normal"]
|
|
752
|
+
size = normal.font.size.pt if normal.font.size else 11
|
|
753
|
+
multiple = normal.paragraph_format.line_spacing if isinstance(normal.paragraph_format.line_spacing, float) else 1.15
|
|
754
|
+
clear = Pt(max(8, round(size * multiple * 1.2 * 0.75)))
|
|
755
|
+
for el in body.iterchildren(qn("w:tbl")):
|
|
756
|
+
nxt = el.getnext()
|
|
757
|
+
if nxt is not None and nxt.tag == qn("w:p") and "".join(t.text or "" for t in nxt.iter(qn("w:t"))).strip():
|
|
758
|
+
para = Paragraph(nxt, doc._body)
|
|
759
|
+
# A heading keeps its own (larger) space above.
|
|
760
|
+
own = para.style.paragraph_format.space_before if para.style is not None else None
|
|
761
|
+
fmt = para.paragraph_format
|
|
762
|
+
if not (own and own >= clear) and (not fmt.space_before or fmt.space_before < clear):
|
|
763
|
+
fmt.space_before = clear
|
|
764
|
+
prev = el.getprevious()
|
|
765
|
+
if prev is not None and prev.tag == qn("w:p") and docx_design.TABLE_CAPTION.match("".join(t.text or "" for t in prev.iter(qn("w:t"))).strip()):
|
|
766
|
+
fmt = Paragraph(prev, doc._body).paragraph_format
|
|
767
|
+
if not fmt.space_before or fmt.space_before < Pt(8):
|
|
768
|
+
fmt.space_before = Pt(8)
|
|
769
|
+
docx_design.space_after_lists(doc)
|
|
770
|
+
for p in doc.paragraphs:
|
|
771
|
+
prev = p._p.getprevious()
|
|
772
|
+
under_picture = prev is not None and prev.find(f".//{qn('w:drawing')}") is not None
|
|
773
|
+
if docx_design.FIGURE_LABEL.match(p.text.strip()) and (under_picture or (p.style is not None and p.style.name == "Caption")):
|
|
774
|
+
if not p.paragraph_format.space_after or p.paragraph_format.space_after < Pt(8):
|
|
775
|
+
p.paragraph_format.space_after = Pt(8)
|
|
776
|
+
|
|
777
|
+
|
|
778
|
+
def plain_table_cells(doc) -> None:
|
|
779
|
+
"""Table cells neither hyphenate nor justify: the body may (the profile's hyphenation), but a narrow cell
|
|
780
|
+
broke "Pro-posed" and a justified header spread "Conversion at"."""
|
|
781
|
+
for table in doc.tables:
|
|
782
|
+
for row in table.rows:
|
|
783
|
+
for cell in row.cells:
|
|
784
|
+
for paragraph in cell.paragraphs:
|
|
785
|
+
p_pr = paragraph._p.get_or_add_pPr()
|
|
786
|
+
if p_pr.find(qn("w:suppressAutoHyphens")) is None:
|
|
787
|
+
p_pr.insert_element_before(OxmlElement("w:suppressAutoHyphens"), *SUPPRESS_AFTER)
|
|
788
|
+
if paragraph.alignment in (None, WD_ALIGN_PARAGRAPH.JUSTIFY):
|
|
789
|
+
paragraph.alignment = WD_ALIGN_PARAGRAPH.LEFT
|
|
790
|
+
|
|
791
|
+
|
|
792
|
+
SUPPRESS_AFTER = ("w:kinsoku", "w:wordWrap", "w:overflowPunct", "w:topLinePunct", "w:autoSpaceDE", "w:autoSpaceDN", "w:bidi",
|
|
793
|
+
"w:adjustRightInd", "w:snapToGrid", "w:spacing", "w:ind", "w:contextualSpacing", "w:mirrorIndents",
|
|
794
|
+
"w:suppressOverlap", "w:jc", "w:textDirection", "w:textAlignment", "w:textboxTightWrap",
|
|
795
|
+
"w:outlineLvl", "w:divId", "w:cnfStyle", "w:rPr", "w:sectPr", "w:pPrChange")
|
|
796
|
+
|
|
797
|
+
|
|
798
|
+
def page_numbers(doc, design: dict) -> None:
|
|
799
|
+
"""The profile's page numbering (registry design.header_footer): a centred PAGE field in the footer at the
|
|
800
|
+
footer size, and none on the first page when the profile says so (its header keeps the notice tag)."""
|
|
801
|
+
hf = design.get("header_footer") or {}
|
|
802
|
+
if hf.get("page_numbering") != "bottom_center":
|
|
803
|
+
return
|
|
804
|
+
size = Pt(float(hf.get("footer_font_size_pt") or 9))
|
|
805
|
+
for index, section in enumerate(doc.sections):
|
|
806
|
+
if index and section.footer.is_linked_to_previous:
|
|
807
|
+
continue
|
|
808
|
+
if not hf.get("page_numbering_first_page", True) and not section.different_first_page_header_footer:
|
|
809
|
+
section.different_first_page_header_footer = True
|
|
810
|
+
first = section.first_page_header
|
|
811
|
+
for paragraph in section.header.paragraphs:
|
|
812
|
+
first._element.append(copy.deepcopy(paragraph._p))
|
|
813
|
+
first._element.remove(first.paragraphs[0]._p)
|
|
814
|
+
footer = section.footer
|
|
815
|
+
paragraph = footer.paragraphs[0]
|
|
816
|
+
paragraph.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
|
817
|
+
run = paragraph.add_run()
|
|
818
|
+
run.font.size = size
|
|
819
|
+
for kind, text in (("begin", None), (None, " PAGE "), ("separate", None), (None, "1"), ("end", None)):
|
|
820
|
+
if kind:
|
|
821
|
+
char = OxmlElement("w:fldChar")
|
|
822
|
+
char.set(qn("w:fldCharType"), kind)
|
|
823
|
+
run._r.append(char)
|
|
824
|
+
elif text == " PAGE ":
|
|
825
|
+
instr = OxmlElement("w:instrText")
|
|
826
|
+
instr.set(qn("xml:space"), "preserve")
|
|
827
|
+
instr.text = text
|
|
828
|
+
run._r.append(instr)
|
|
829
|
+
else:
|
|
830
|
+
t = OxmlElement("w:t")
|
|
831
|
+
t.text = text
|
|
832
|
+
run._r.append(t)
|
|
833
|
+
|
|
834
|
+
|
|
693
835
|
def strip_banned(doc, design: dict) -> None:
|
|
694
836
|
"""Remove any banned font/style from runs and paragraphs."""
|
|
695
837
|
banned = design.get("banned") or {}
|
|
@@ -734,6 +876,19 @@ def _set_run_fonts(run, latin: str, cjk: str | None) -> None:
|
|
|
734
876
|
rfonts.set(qn("w:eastAsia"), cjk)
|
|
735
877
|
|
|
736
878
|
|
|
879
|
+
THEME_PAIRS = (("asciiTheme", "ascii"), ("hAnsiTheme", "hAnsi"), ("eastAsiaTheme", "eastAsia"), ("cstheme", "cs"))
|
|
880
|
+
|
|
881
|
+
|
|
882
|
+
def drop_shadowed_theme_fonts(doc) -> None:
|
|
883
|
+
"""A style that names its face keeps no theme font beside it: Word and LibreOffice both let the theme
|
|
884
|
+
attribute win, so the profile's heading face (Times New Roman, Arial, Pretendard) rendered as the
|
|
885
|
+
theme's Calibri. Styles only; the document body is untouched."""
|
|
886
|
+
for rfonts in doc.styles.element.iter(qn("w:rFonts")):
|
|
887
|
+
for theme, named in THEME_PAIRS:
|
|
888
|
+
if rfonts.get(qn(f"w:{named}")) and rfonts.get(qn(f"w:{theme}")) is not None:
|
|
889
|
+
del rfonts.attrib[qn(f"w:{theme}")]
|
|
890
|
+
|
|
891
|
+
|
|
737
892
|
def apply_cjk_font_pairing(doc, font_cfg: dict) -> None:
|
|
738
893
|
body = font_cfg.get("body") or {}
|
|
739
894
|
body_cjk = font_cfg.get("body_cjk") or {}
|
|
@@ -947,8 +1102,7 @@ def detect_locale(text: str) -> str:
|
|
|
947
1102
|
# ===========================================================================
|
|
948
1103
|
|
|
949
1104
|
|
|
950
|
-
NOTICE_FILL =
|
|
951
|
-
NOTICE_INK = RGBColor(0xA2, 0x1B, 0x12)
|
|
1105
|
+
NOTICE_INK, NOTICE_FILL = docx_design.NOTICE
|
|
952
1106
|
|
|
953
1107
|
|
|
954
1108
|
def _shade(element, fill: str) -> None:
|
|
@@ -960,7 +1114,7 @@ def _shade(element, fill: str) -> None:
|
|
|
960
1114
|
|
|
961
1115
|
|
|
962
1116
|
def add_notice(doc, text: str) -> None:
|
|
963
|
-
"""The frontmatter `notice:` (e.g. "예시
|
|
1117
|
+
"""The frontmatter `notice:` (e.g. "예시 데이터: 수치는 가정입니다") as a tag in
|
|
964
1118
|
every page header, so an example document cannot be mistaken for a sourced one
|
|
965
1119
|
on any page it is printed from. A profile whose title page has its own, empty
|
|
966
1120
|
header (korean-generic) gets a tinted band under the title block instead."""
|
|
@@ -969,7 +1123,7 @@ def add_notice(doc, text: str) -> None:
|
|
|
969
1123
|
band_run = band.add_run(str(text))
|
|
970
1124
|
band_run.bold = True
|
|
971
1125
|
band_run.font.size = Pt(10.5)
|
|
972
|
-
band_run.font.color.rgb = NOTICE_INK
|
|
1126
|
+
band_run.font.color.rgb = RGBColor.from_string(NOTICE_INK)
|
|
973
1127
|
band.paragraph_format.space_before = Pt(4)
|
|
974
1128
|
band.paragraph_format.space_after = Pt(14)
|
|
975
1129
|
_shade(band._p.get_or_add_pPr(), NOTICE_FILL)
|
|
@@ -981,38 +1135,10 @@ def add_notice(doc, text: str) -> None:
|
|
|
981
1135
|
tag_run = tag.add_run(f"\u00a0{text}\u00a0")
|
|
982
1136
|
tag_run.bold = True
|
|
983
1137
|
tag_run.font.size = Pt(9)
|
|
984
|
-
tag_run.font.color.rgb = NOTICE_INK
|
|
1138
|
+
tag_run.font.color.rgb = RGBColor.from_string(NOTICE_INK)
|
|
985
1139
|
_shade(tag_run._r.get_or_add_rPr(), NOTICE_FILL)
|
|
986
1140
|
|
|
987
1141
|
|
|
988
|
-
def add_plain_title_block(doc, front: dict) -> None:
|
|
989
|
-
"""Title, optional subtitle, and one line of author / organisation / date,
|
|
990
|
-
left-aligned above the body, for the plain (non-publisher) profile."""
|
|
991
|
-
title = doc.add_paragraph()
|
|
992
|
-
run = title.add_run(str(front["title"]))
|
|
993
|
-
run.bold = True
|
|
994
|
-
run.font.size = Pt(22)
|
|
995
|
-
title.paragraph_format.space_after = Pt(4)
|
|
996
|
-
if front.get("subtitle"):
|
|
997
|
-
sub_p = doc.add_paragraph()
|
|
998
|
-
sub_run = sub_p.add_run(str(front["subtitle"]))
|
|
999
|
-
sub_run.font.size = Pt(13)
|
|
1000
|
-
sub_run.font.color.rgb = RGBColor(0x55, 0x5F, 0x6D)
|
|
1001
|
-
sub_p.paragraph_format.space_after = Pt(4)
|
|
1002
|
-
authors = front.get("authors") or front.get("author")
|
|
1003
|
-
if isinstance(authors, list):
|
|
1004
|
-
authors = ", ".join(str(a.get("name", a)) if isinstance(a, dict) else str(a) for a in authors)
|
|
1005
|
-
meta = [str(v) for v in (authors, front.get("organization"), front.get("date")) if v]
|
|
1006
|
-
if meta:
|
|
1007
|
-
meta_p = doc.add_paragraph()
|
|
1008
|
-
meta_run = meta_p.add_run(" · ".join(meta))
|
|
1009
|
-
meta_run.font.size = Pt(10.5)
|
|
1010
|
-
meta_run.font.color.rgb = RGBColor(0x55, 0x5F, 0x6D)
|
|
1011
|
-
meta_p.paragraph_format.space_after = Pt(18)
|
|
1012
|
-
if front.get("notice"):
|
|
1013
|
-
add_notice(doc, front["notice"])
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
1142
|
def read_source(md_path: Path) -> str:
|
|
1017
1143
|
"""The Markdown source, with {{ … }} numbers filled from its `data:` files
|
|
1018
1144
|
(lib/office_data.py computes derived values and runs the declared checks)."""
|
|
@@ -1023,6 +1149,31 @@ def read_source(md_path: Path) -> str:
|
|
|
1023
1149
|
raise SystemExit(f"Data error: {exc}") from exc
|
|
1024
1150
|
|
|
1025
1151
|
|
|
1152
|
+
MD_EXTENSIONS = ['tables', 'fenced_code', 'nl2br', 'sane_lists']
|
|
1153
|
+
|
|
1154
|
+
|
|
1155
|
+
def _md_renderer(md_path: Path):
|
|
1156
|
+
"""Markdown into a document or a component's cell, through the legacy HTML helpers."""
|
|
1157
|
+
def render(container, text: str) -> None:
|
|
1158
|
+
html_to_docx(markdown.Markdown(extensions=MD_EXTENSIONS).convert(text), container, md_path)
|
|
1159
|
+
return render
|
|
1160
|
+
|
|
1161
|
+
|
|
1162
|
+
def _typography(design: dict, lang: str = "en"):
|
|
1163
|
+
"""The registry's micro-typography filters, for Markdown outside the directive fences; Korean text also
|
|
1164
|
+
writes ISO dates the Korean way (2026. 10. 1.)."""
|
|
1165
|
+
def apply(text: str) -> str:
|
|
1166
|
+
# Dates first: the dash filter would read 2026-06-30 as a number range.
|
|
1167
|
+
if lang != "en":
|
|
1168
|
+
text = docx_design.korean_dates(text)
|
|
1169
|
+
text = fix_dashes(text, design.get("micro_typography", {}).get("dashes"))
|
|
1170
|
+
text = fix_quotes(text)
|
|
1171
|
+
text = fix_unit_spacing(text)
|
|
1172
|
+
text = fix_ellipsis(text)
|
|
1173
|
+
return normalize_double_spaces(text)
|
|
1174
|
+
return apply
|
|
1175
|
+
|
|
1176
|
+
|
|
1026
1177
|
def convert_md_to_docx(
|
|
1027
1178
|
md_path: Path,
|
|
1028
1179
|
docx_path: Path,
|
|
@@ -1030,40 +1181,43 @@ def convert_md_to_docx(
|
|
|
1030
1181
|
publisher_name: str | None = None,
|
|
1031
1182
|
registry_path: Path | None = None,
|
|
1032
1183
|
locale: str = "auto",
|
|
1184
|
+
tonality: str | None = None,
|
|
1185
|
+
density: int | None = None,
|
|
1186
|
+
variance: int | None = None,
|
|
1033
1187
|
):
|
|
1034
1188
|
"""Convert Markdown file to DOCX.
|
|
1035
1189
|
|
|
1036
|
-
|
|
1037
|
-
|
|
1190
|
+
With a tonality (argument or frontmatter `tonality:`), the document is built
|
|
1191
|
+
by docx_design in that design direction. Without a tonality or a publisher,
|
|
1192
|
+
the plain profile runs through the same builders with neutral tokens (A4,
|
|
1193
|
+
Pretendard, booktabs tables, notice in every header).
|
|
1038
1194
|
|
|
1039
1195
|
When publisher_name is set, apply the full journal pipeline: registry
|
|
1040
1196
|
load, frontmatter parse, pre-MD filters, title-block injection, post-HTML
|
|
1041
1197
|
heading numbering, booktabs tables, banned stripping, CJK font pairing.
|
|
1198
|
+
A publisher run writes the same document.xml as before the tonality
|
|
1199
|
+
engine unless the source holds figures (captions) or directives.
|
|
1042
1200
|
"""
|
|
1043
1201
|
if publisher_name is None:
|
|
1044
|
-
# --- Legacy code path (regression-critical: do not mutate) ---
|
|
1045
1202
|
md_text = read_source(md_path)
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
if
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
doc = Document()
|
|
1062
|
-
if plain_front.get("title"):
|
|
1063
|
-
add_plain_title_block(doc, plain_front)
|
|
1064
|
-
html_to_docx(html, doc, md_path)
|
|
1203
|
+
front, body = parse_frontmatter(md_text) if md_text.startswith("---") else ({}, md_text)
|
|
1204
|
+
name = tonality or front.get("tonality")
|
|
1205
|
+
lang = "en" if (detect_locale(body) if locale == "auto" else locale) == "en" else "ko"
|
|
1206
|
+
dials = {k: v for k, v in (("density", density if density is not None else front.get("density")),
|
|
1207
|
+
("variance", variance if variance is not None else front.get("variance"))) if v is not None}
|
|
1208
|
+
try:
|
|
1209
|
+
if name:
|
|
1210
|
+
T = docx_design.load_tonality(name, locale=lang, **{k: int(v) for k, v in dials.items()})
|
|
1211
|
+
filters = _typography(T.design, lang)
|
|
1212
|
+
else:
|
|
1213
|
+
T, filters = docx_design.neutral_tonality(lang), None
|
|
1214
|
+
doc = Document(str(template_path)) if template_path and template_path.exists() else Document()
|
|
1215
|
+
docx_design.build(doc, front, body, T, _md_renderer(md_path), lang, filters)
|
|
1216
|
+
except docx_design.DesignError as exc:
|
|
1217
|
+
raise SystemExit(f"Error: {exc}") from exc
|
|
1065
1218
|
doc.save(str(docx_path))
|
|
1066
|
-
|
|
1219
|
+
label = f"tonality={T.name}, density={T.density}, variance={T.variance}" if not T.neutral else "plain profile"
|
|
1220
|
+
print(f"Successfully converted {md_path} to {docx_path} [{label}, locale={lang}]")
|
|
1067
1221
|
return
|
|
1068
1222
|
|
|
1069
1223
|
# --- Journal workflow ---
|
|
@@ -1075,17 +1229,29 @@ def convert_md_to_docx(
|
|
|
1075
1229
|
|
|
1076
1230
|
md_text = read_source(md_path)
|
|
1077
1231
|
frontmatter, body = parse_frontmatter(md_text)
|
|
1232
|
+
if frontmatter.get("tonality"):
|
|
1233
|
+
raise SystemExit(f"Error: frontmatter tonality: {frontmatter['tonality']} and --publisher {publisher_name} are mutually exclusive")
|
|
1078
1234
|
|
|
1079
1235
|
# Locale detection (informational in M1; Korean rules land in M6)
|
|
1080
1236
|
detected_locale = detect_locale(body) if locale == "auto" else locale
|
|
1081
1237
|
|
|
1082
1238
|
# Pre-MD micro-typography filters
|
|
1239
|
+
if detected_locale in ("ko", "mixed"):
|
|
1240
|
+
# Spec Amendments 4, A4.10 (every path): no ISO dates in Korean text. Before the dash filter, which would
|
|
1241
|
+
# read 2026-06-30 as a number range.
|
|
1242
|
+
body = docx_design.korean_dates(body)
|
|
1243
|
+
if frontmatter.get("date"):
|
|
1244
|
+
frontmatter["date"] = docx_design.format_date(frontmatter["date"], "ko")
|
|
1083
1245
|
dash_rules = design.get("micro_typography", {}).get("dashes")
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1246
|
+
|
|
1247
|
+
def typeset(text: str) -> str:
|
|
1248
|
+
return normalize_double_spaces(fix_ellipsis(fix_unit_spacing(fix_quotes(fix_dashes(text, dash_rules)))))
|
|
1249
|
+
|
|
1250
|
+
# A ::: fence line carries attributes (title="..."); curly quotes there would break the directive. The text
|
|
1251
|
+
# between fence lines is typeset as whole runs, so code fences and quote pairs keep their context and a
|
|
1252
|
+
# source without fences is typeset exactly as one piece.
|
|
1253
|
+
parts = re.split(r"^([ \t]*:{3,}.*)$", body, flags=re.M)
|
|
1254
|
+
body = "".join(part if i % 2 else typeset(part) for i, part in enumerate(parts))
|
|
1089
1255
|
|
|
1090
1256
|
# Load template if available
|
|
1091
1257
|
template_docx = script_dir.parent / "templates" / "docx" / f"{publisher_name}.docx"
|
|
@@ -1112,22 +1278,44 @@ def convert_md_to_docx(
|
|
|
1112
1278
|
inject_title_block(doc, frontmatter, design)
|
|
1113
1279
|
if frontmatter.get("notice"):
|
|
1114
1280
|
add_notice(doc, frontmatter["notice"])
|
|
1281
|
+
page_numbers(doc, design)
|
|
1115
1282
|
|
|
1116
1283
|
# Main content
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1284
|
+
if docx_design.has_directives(body):
|
|
1285
|
+
# Components in a journal manuscript are drawn with neutral styling (ink and line only).
|
|
1286
|
+
neutral = docx_design.neutral_tonality("en" if detected_locale == "en" else "ko")
|
|
1287
|
+
docx_design.setup_component_styles(doc, neutral)
|
|
1288
|
+
ctx = docx_design.Build(doc, neutral, _md_renderer(md_path), "en" if detected_locale == "en" else "ko")
|
|
1289
|
+
try:
|
|
1290
|
+
ctx.render(doc, body)
|
|
1291
|
+
except docx_design.DesignError as exc:
|
|
1292
|
+
raise SystemExit(f"Error: {exc}") from exc
|
|
1293
|
+
print("Note: the ::: components are drawn with publisher-neutral styling under a publisher profile")
|
|
1294
|
+
else:
|
|
1295
|
+
md_converter = markdown.Markdown(extensions=[
|
|
1296
|
+
'tables',
|
|
1297
|
+
'fenced_code',
|
|
1298
|
+
'nl2br',
|
|
1299
|
+
'sane_lists',
|
|
1300
|
+
])
|
|
1301
|
+
html = md_converter.convert(body)
|
|
1302
|
+
html_to_docx(html, doc, md_path)
|
|
1125
1303
|
|
|
1126
1304
|
# Post-HTML design enforcement
|
|
1127
1305
|
apply_heading_numbering(doc, design)
|
|
1128
1306
|
apply_table_style(doc, design)
|
|
1307
|
+
keep_tables_with_captions(doc)
|
|
1308
|
+
plain_table_cells(doc)
|
|
1309
|
+
publisher_spacing(doc)
|
|
1310
|
+
# No column narrower than its longest word (spec Amendments 4, todo 12b: "Cataly/st" in a publisher table).
|
|
1311
|
+
normal = doc.styles["Normal"].font.size
|
|
1312
|
+
for table in doc.tables:
|
|
1313
|
+
docx_design.fit_word_widths(table, normal.pt if normal else 11)
|
|
1314
|
+
docx_design.restart_numbered_lists(doc)
|
|
1315
|
+
docx_design.keep_short_lists(doc)
|
|
1129
1316
|
strip_banned(doc, design)
|
|
1130
1317
|
apply_cjk_font_pairing(doc, docx_font)
|
|
1318
|
+
drop_shadowed_theme_fonts(doc)
|
|
1131
1319
|
|
|
1132
1320
|
doc.save(str(docx_path))
|
|
1133
1321
|
print(
|
|
@@ -1157,6 +1345,14 @@ def parse_args() -> argparse.Namespace:
|
|
|
1157
1345
|
help="Path to templates/registry.yaml (defaults to the one in this skill)",
|
|
1158
1346
|
default=None,
|
|
1159
1347
|
)
|
|
1348
|
+
parser.add_argument(
|
|
1349
|
+
"--tonality",
|
|
1350
|
+
help="Design direction from templates/tonalities/: Report, Brief, Manual, Proposal, Memo or Journal "
|
|
1351
|
+
"(frontmatter tonality: works too)",
|
|
1352
|
+
default=None,
|
|
1353
|
+
)
|
|
1354
|
+
parser.add_argument("--density", type=int, default=None, help="Density dial 1-10 (default: the pack's)")
|
|
1355
|
+
parser.add_argument("--variance", type=int, default=None, help="Variance dial 1-10 (default: the pack's)")
|
|
1160
1356
|
parser.add_argument(
|
|
1161
1357
|
"--locale",
|
|
1162
1358
|
choices=("auto", "en", "ko", "mixed"),
|
|
@@ -1166,6 +1362,8 @@ def parse_args() -> argparse.Namespace:
|
|
|
1166
1362
|
args = parser.parse_args()
|
|
1167
1363
|
if args.publisher and args.template:
|
|
1168
1364
|
parser.error("--publisher and --template are mutually exclusive")
|
|
1365
|
+
if args.publisher and args.tonality:
|
|
1366
|
+
parser.error("--tonality and --publisher are mutually exclusive: a tonality is a design direction, a publisher a journal profile")
|
|
1169
1367
|
return args
|
|
1170
1368
|
|
|
1171
1369
|
|
|
@@ -1186,6 +1384,9 @@ def main() -> int:
|
|
|
1186
1384
|
publisher_name=args.publisher,
|
|
1187
1385
|
registry_path=registry_path,
|
|
1188
1386
|
locale=args.locale,
|
|
1387
|
+
tonality=args.tonality,
|
|
1388
|
+
density=args.density,
|
|
1389
|
+
variance=args.variance,
|
|
1189
1390
|
)
|
|
1190
1391
|
return 0
|
|
1191
1392
|
|