@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.
Files changed (101) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +14 -8
  3. package/README_ko-KR.md +13 -8
  4. package/docs/assets/readme/badge-version.svg +1 -1
  5. package/docs/hooks.md +3 -2
  6. package/package.json +1 -1
  7. package/plugins/litclaude/.claude-plugin/plugin.json +1 -1
  8. package/plugins/litclaude/bin/litclaude-hook.js +4 -4
  9. package/plugins/litclaude/bin/litclaude-mcp.js +1 -1
  10. package/plugins/litclaude/lib/canonical-skill-resources.mjs +91 -31
  11. package/plugins/litclaude/skills/frontend-ui-ux/ATTRIBUTION.md +3 -6
  12. package/plugins/litclaude/skills/lit-docx/SKILL.md +50 -19
  13. package/plugins/litclaude/skills/lit-docx/references/components.md +207 -0
  14. package/plugins/litclaude/skills/lit-docx/references/direction-step.md +277 -0
  15. package/plugins/litclaude/skills/lit-docx/references/examples/01-report-ko.md +166 -0
  16. package/plugins/litclaude/skills/lit-docx/references/examples/02-brief-ko.md +105 -0
  17. package/plugins/litclaude/skills/lit-docx/references/examples/03-manual-ko.md +164 -0
  18. package/plugins/litclaude/skills/lit-docx/references/examples/04-proposal-ko.md +138 -0
  19. package/plugins/litclaude/skills/lit-docx/references/examples/05-memo-en.md +55 -0
  20. package/plugins/litclaude/skills/lit-docx/references/examples/06-journal-en.md +130 -0
  21. package/plugins/litclaude/skills/lit-docx/references/examples/07-report-en.md +123 -0
  22. package/plugins/litclaude/skills/lit-docx/references/examples/08-proposal-en.md +123 -0
  23. package/plugins/litclaude/skills/lit-docx/references/examples/09-memo-ko.md +45 -0
  24. package/plugins/litclaude/skills/lit-docx/references/page-composition.md +221 -0
  25. package/plugins/litclaude/skills/lit-docx/references/tonalities/brief.md +143 -0
  26. package/plugins/litclaude/skills/lit-docx/references/tonalities/journal.md +142 -0
  27. package/plugins/litclaude/skills/lit-docx/references/tonalities/manual.md +148 -0
  28. package/plugins/litclaude/skills/lit-docx/references/tonalities/memo.md +137 -0
  29. package/plugins/litclaude/skills/lit-docx/references/tonalities/proposal.md +140 -0
  30. package/plugins/litclaude/skills/lit-docx/references/tonalities/report.md +156 -0
  31. package/plugins/litclaude/skills/lit-docx/scripts/convert_md_to_docx.py +274 -73
  32. package/plugins/litclaude/skills/lit-docx/scripts/docx_design.py +2449 -0
  33. package/plugins/litclaude/skills/lit-docx/scripts/docx_layout.py +959 -0
  34. package/plugins/litclaude/skills/lit-docx/scripts/qa_docx.py +66 -9
  35. package/plugins/litclaude/skills/lit-docx/scripts/slop_lint.py +18 -6
  36. package/plugins/litclaude/skills/lit-docx/templates/tonalities/brief.yaml +28 -0
  37. package/plugins/litclaude/skills/lit-docx/templates/tonalities/journal.yaml +25 -0
  38. package/plugins/litclaude/skills/lit-docx/templates/tonalities/manual.yaml +26 -0
  39. package/plugins/litclaude/skills/lit-docx/templates/tonalities/memo.yaml +26 -0
  40. package/plugins/litclaude/skills/lit-docx/templates/tonalities/proposal.yaml +26 -0
  41. package/plugins/litclaude/skills/lit-docx/templates/tonalities/report.yaml +26 -0
  42. package/plugins/litclaude/skills/lit-pptx/SKILL.md +105 -48
  43. package/plugins/litclaude/skills/lit-pptx/references/agents/analyst.md +1 -1
  44. package/plugins/litclaude/skills/lit-pptx/references/agents/thesis.md +1 -1
  45. package/plugins/litclaude/skills/lit-pptx/references/anti-slop-checklist.md +59 -30
  46. package/plugins/litclaude/skills/lit-pptx/references/authoring-guide.md +119 -42
  47. package/plugins/litclaude/skills/lit-pptx/references/consensus-protocol.md +1 -1
  48. package/plugins/litclaude/skills/lit-pptx/references/data-display-cookbook.md +13 -6
  49. package/plugins/litclaude/skills/lit-pptx/references/density-and-fill.md +164 -0
  50. package/plugins/litclaude/skills/lit-pptx/references/design-system-azure.md +7 -4
  51. package/plugins/litclaude/skills/lit-pptx/references/direction-step.md +186 -0
  52. package/plugins/litclaude/skills/lit-pptx/references/examples/01-pitch-signal-ko.md +210 -0
  53. package/plugins/litclaude/skills/lit-pptx/references/examples/02-pitch-studio-ko.md +147 -0
  54. package/plugins/litclaude/skills/lit-pptx/references/examples/03-business-review-ledger-ko.md +199 -0
  55. package/plugins/litclaude/skills/lit-pptx/references/examples/04-business-review-night-en.md +203 -0
  56. package/plugins/litclaude/skills/lit-pptx/references/examples/05-research-talk-paper-en.md +179 -0
  57. package/plugins/litclaude/skills/lit-pptx/references/examples/06-lecture-chalk-ko.md +199 -0
  58. package/plugins/litclaude/skills/lit-pptx/references/examples/07-data-review-night-en.md +194 -0
  59. package/plugins/litclaude/skills/lit-pptx/references/examples/08-image-led-atlas-ko.md +175 -0
  60. package/plugins/litclaude/skills/lit-pptx/references/examples/09-briefing-gazette-ko.md +204 -0
  61. package/plugins/litclaude/skills/lit-pptx/references/examples/10-status-update-ledger-ko.md +179 -0
  62. package/plugins/litclaude/skills/lit-pptx/references/examples/11-portfolio-studio-en.md +164 -0
  63. package/plugins/litclaude/skills/lit-pptx/references/examples/12-research-tutorial-chalk-en.md +174 -0
  64. package/plugins/litclaude/skills/lit-pptx/references/interview-protocol.md +1 -1
  65. package/plugins/litclaude/skills/lit-pptx/references/layout-families.md +198 -0
  66. package/plugins/litclaude/skills/lit-pptx/references/stages/autopilot.md +1 -1
  67. package/plugins/litclaude/skills/lit-pptx/references/stages/interview.md +2 -2
  68. package/plugins/litclaude/skills/lit-pptx/references/stages/plan.md +1 -1
  69. package/plugins/litclaude/skills/lit-pptx/references/title-treatments.md +109 -0
  70. package/plugins/litclaude/skills/lit-pptx/references/tonalities/atlas.md +139 -0
  71. package/plugins/litclaude/skills/lit-pptx/references/tonalities/chalk.md +148 -0
  72. package/plugins/litclaude/skills/lit-pptx/references/tonalities/gazette.md +149 -0
  73. package/plugins/litclaude/skills/lit-pptx/references/tonalities/ledger.md +149 -0
  74. package/plugins/litclaude/skills/lit-pptx/references/tonalities/night.md +147 -0
  75. package/plugins/litclaude/skills/lit-pptx/references/tonalities/paper.md +151 -0
  76. package/plugins/litclaude/skills/lit-pptx/references/tonalities/signal.md +150 -0
  77. package/plugins/litclaude/skills/lit-pptx/references/tonalities/studio.md +132 -0
  78. package/plugins/litclaude/skills/lit-pptx/scripts/compile-deck.js +64 -16
  79. package/plugins/litclaude/skills/lit-pptx/scripts/craft_extras.py +664 -3
  80. package/plugins/litclaude/skills/lit-pptx/scripts/deck_craft.py +48 -6
  81. package/plugins/litclaude/skills/lit-pptx/scripts/lib/grid-resolver.js +521 -0
  82. package/plugins/litclaude/skills/lit-pptx/scripts/lib/layout-resolver.js +362 -11
  83. package/plugins/litclaude/skills/lit-pptx/scripts/lib/markdown-loader.js +6 -4
  84. package/plugins/litclaude/skills/lit-pptx/scripts/lib/render-adapter-pptx.js +211 -65
  85. package/plugins/litclaude/skills/lit-pptx/scripts/lib/render-pack.js +3559 -0
  86. package/plugins/litclaude/skills/lit-pptx/scripts/lib/slide-ast.js +10 -6
  87. package/plugins/litclaude/skills/lit-pptx/scripts/lib/template-registry.js +286 -1
  88. package/plugins/litclaude/skills/lit-pptx/scripts/qa_deck.py +39 -10
  89. package/plugins/litclaude/skills/lit-pptx/scripts/validate_pptx.py +22 -7
  90. package/plugins/litclaude/skills/lit-pptx/templates/enrolled/AZURE-A2Z/layout-mapping.yaml +1 -1
  91. package/plugins/litclaude/skills/lit-pptx/templates/enrolled/AZURE-A2Z/template.yaml +4 -1
  92. package/plugins/litclaude/skills/lit-pptx/templates/enrolled/AZURE-PRO/layout-mapping.yaml +1 -1
  93. package/plugins/litclaude/skills/lit-pptx/templates/enrolled/AZURE-PRO/template.yaml +4 -1
  94. package/plugins/litclaude/skills/lit-pptx/templates/tonalities/atlas/pack.yaml +92 -0
  95. package/plugins/litclaude/skills/lit-pptx/templates/tonalities/chalk/pack.yaml +97 -0
  96. package/plugins/litclaude/skills/lit-pptx/templates/tonalities/gazette/pack.yaml +93 -0
  97. package/plugins/litclaude/skills/lit-pptx/templates/tonalities/ledger/pack.yaml +94 -0
  98. package/plugins/litclaude/skills/lit-pptx/templates/tonalities/night/pack.yaml +93 -0
  99. package/plugins/litclaude/skills/lit-pptx/templates/tonalities/paper/pack.yaml +98 -0
  100. package/plugins/litclaude/skills/lit-pptx/templates/tonalities/signal/pack.yaml +94 -0
  101. 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
- for child in li.children:
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).strip()
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 = "FDECEA"
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. "예시 데이터 — 수치는 가정입니다") as a tag in
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
- When publisher_name is None, behavior is byte-compatible with the pre-M1
1037
- script: read md, convert to HTML, html_to_docx, save. Regression-guarded.
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
- # A frontmatter block is a title page request, not body text: strip it and
1047
- # set a plain title block. Markdown without frontmatter converts as before.
1048
- plain_front = {}
1049
- if md_text.startswith("---"):
1050
- plain_front, md_text = parse_frontmatter(md_text)
1051
- md_converter = markdown.Markdown(extensions=[
1052
- 'tables',
1053
- 'fenced_code',
1054
- 'nl2br',
1055
- 'sane_lists',
1056
- ])
1057
- html = md_converter.convert(md_text)
1058
- if template_path and template_path.exists():
1059
- doc = Document(str(template_path))
1060
- else:
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
- print(f"Successfully converted {md_path} to {docx_path}")
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
- body = fix_dashes(body, dash_rules)
1085
- body = fix_quotes(body)
1086
- body = fix_unit_spacing(body)
1087
- body = fix_ellipsis(body)
1088
- body = normalize_double_spaces(body)
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
- md_converter = markdown.Markdown(extensions=[
1118
- 'tables',
1119
- 'fenced_code',
1120
- 'nl2br',
1121
- 'sane_lists',
1122
- ])
1123
- html = md_converter.convert(body)
1124
- html_to_docx(html, doc, md_path)
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