@litfamily/litgrok 1.0.13 → 1.0.15

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 (114) hide show
  1. package/.grok/hooks/auto-handoff-gate.mjs +15 -2
  2. package/.grok/rules/00-litgrok.md +1 -1
  3. package/.grok/skills/frontend-ui-ux/THIRD-PARTY-NOTICE.txt +0 -5
  4. package/.grok/skills/frontend-ui-ux/references/slop-register.md +4 -4
  5. package/.grok/skills/frontend-ui-ux/scripts/import-design-intelligence.mjs +1 -1
  6. package/.grok/skills/lit-docx/SKILL.md +59 -11
  7. package/.grok/skills/lit-docx/examples/01-report-ko.md +137 -0
  8. package/.grok/skills/lit-docx/examples/02-brief-ko.md +82 -0
  9. package/.grok/skills/lit-docx/examples/03-manual-ko.md +118 -0
  10. package/.grok/skills/lit-docx/examples/04-proposal-ko.md +92 -0
  11. package/.grok/skills/lit-docx/examples/05-memo-en.md +42 -0
  12. package/.grok/skills/lit-docx/examples/06-journal-en.md +90 -0
  13. package/.grok/skills/lit-docx/examples/07-report-en.md +103 -0
  14. package/.grok/skills/lit-docx/examples/08-proposal-en.md +90 -0
  15. package/.grok/skills/lit-docx/examples/09-memo-ko.md +41 -0
  16. package/.grok/skills/lit-docx/references/components.md +151 -0
  17. package/.grok/skills/lit-docx/references/direction-step.md +129 -0
  18. package/.grok/skills/lit-docx/references/page-composition.md +129 -0
  19. package/.grok/skills/lit-docx/references/tonalities/brief.md +98 -0
  20. package/.grok/skills/lit-docx/references/tonalities/journal.md +100 -0
  21. package/.grok/skills/lit-docx/references/tonalities/manual.md +107 -0
  22. package/.grok/skills/lit-docx/references/tonalities/memo.md +95 -0
  23. package/.grok/skills/lit-docx/references/tonalities/proposal.md +105 -0
  24. package/.grok/skills/lit-docx/references/tonalities/report.md +114 -0
  25. package/.grok/skills/lit-docx/scripts/convert_md_to_docx.py +394 -51
  26. package/.grok/skills/lit-docx/scripts/docx_design.py +2446 -0
  27. package/.grok/skills/lit-docx/scripts/docx_gate.py +203 -0
  28. package/.grok/skills/lit-docx/scripts/docx_layout.py +959 -0
  29. package/.grok/skills/lit-docx/scripts/run.mjs +3 -2
  30. package/.grok/skills/lit-docx/scripts/slop_lint.py +63 -17
  31. package/.grok/skills/lit-docx/templates/tonalities/brief.yaml +29 -0
  32. package/.grok/skills/lit-docx/templates/tonalities/journal.yaml +26 -0
  33. package/.grok/skills/lit-docx/templates/tonalities/manual.yaml +27 -0
  34. package/.grok/skills/lit-docx/templates/tonalities/memo.yaml +27 -0
  35. package/.grok/skills/lit-docx/templates/tonalities/proposal.yaml +27 -0
  36. package/.grok/skills/lit-docx/templates/tonalities/report.yaml +27 -0
  37. package/.grok/skills/lit-pptx/NOTICE.md +1 -1
  38. package/.grok/skills/lit-pptx/SKILL.md +70 -14
  39. package/.grok/skills/lit-pptx/agents/analyst.md +1 -1
  40. package/.grok/skills/lit-pptx/agents/thesis.md +2 -2
  41. package/.grok/skills/lit-pptx/examples/01-pitch-signal-ko.md +175 -0
  42. package/.grok/skills/lit-pptx/examples/02-business-review-ledger-ko.md +199 -0
  43. package/.grok/skills/lit-pptx/examples/03-research-talk-paper-en.md +177 -0
  44. package/.grok/skills/lit-pptx/examples/04-lecture-chalk-ko.md +176 -0
  45. package/.grok/skills/lit-pptx/examples/05-data-review-night-en.md +221 -0
  46. package/.grok/skills/lit-pptx/examples/06-image-led-atlas-en.md +143 -0
  47. package/.grok/skills/lit-pptx/examples/07-briefing-gazette-ko.md +192 -0
  48. package/.grok/skills/lit-pptx/examples/08-status-update-ledger-en.md +168 -0
  49. package/.grok/skills/lit-pptx/examples/09-pitch-studio-en.md +157 -0
  50. package/.grok/skills/lit-pptx/examples/10-image-led-studio-ko.md +175 -0
  51. package/.grok/skills/lit-pptx/examples/11-business-review-night-en.md +181 -0
  52. package/.grok/skills/lit-pptx/examples/12-status-update-gazette-ko.md +199 -0
  53. package/.grok/skills/lit-pptx/pretendard-font/public/static/Pretendard-Bold.otf +0 -0
  54. package/.grok/skills/lit-pptx/pretendard-font/public/static/Pretendard-Regular.otf +0 -0
  55. package/.grok/skills/lit-pptx/references/density-and-fill.md +155 -0
  56. package/.grok/skills/lit-pptx/references/direction-step.md +211 -0
  57. package/.grok/skills/lit-pptx/references/layout-families.md +193 -0
  58. package/.grok/skills/lit-pptx/references/title-treatments.md +134 -0
  59. package/.grok/skills/lit-pptx/references/tonalities/atlas.md +124 -0
  60. package/.grok/skills/lit-pptx/references/tonalities/chalk.md +135 -0
  61. package/.grok/skills/lit-pptx/references/tonalities/gazette.md +127 -0
  62. package/.grok/skills/lit-pptx/references/tonalities/ledger.md +134 -0
  63. package/.grok/skills/lit-pptx/references/tonalities/night.md +132 -0
  64. package/.grok/skills/lit-pptx/references/tonalities/paper.md +127 -0
  65. package/.grok/skills/lit-pptx/references/tonalities/signal.md +118 -0
  66. package/.grok/skills/lit-pptx/references/tonalities/studio.md +123 -0
  67. package/.grok/skills/lit-pptx/scripts/compile-deck.js +65 -17
  68. package/.grok/skills/lit-pptx/scripts/craft_extras.py +82 -18
  69. package/.grok/skills/lit-pptx/scripts/deck_output.py +598 -0
  70. package/.grok/skills/lit-pptx/scripts/embed_fonts.py +1 -1
  71. package/.grok/skills/lit-pptx/scripts/inventory.py +6 -2
  72. package/.grok/skills/lit-pptx/scripts/lib/directive-parser.js +2 -2
  73. package/.grok/skills/lit-pptx/scripts/lib/grid-resolver.js +521 -0
  74. package/.grok/skills/lit-pptx/scripts/lib/layout-resolver.js +401 -16
  75. package/.grok/skills/lit-pptx/scripts/lib/markdown-loader.js +6 -4
  76. package/.grok/skills/lit-pptx/scripts/lib/render-adapter-html.js +2 -2
  77. package/.grok/skills/lit-pptx/scripts/lib/render-adapter-pptx.js +500 -126
  78. package/.grok/skills/lit-pptx/scripts/lib/render-pack.js +3558 -0
  79. package/.grok/skills/lit-pptx/scripts/lib/slide-ast.js +54 -6
  80. package/.grok/skills/lit-pptx/scripts/lib/template-registry.js +335 -11
  81. package/.grok/skills/lit-pptx/scripts/qa_deck.py +43 -10
  82. package/.grok/skills/lit-pptx/scripts/run.mjs +3 -2
  83. package/.grok/skills/lit-pptx/scripts/validate_pptx.py +17 -5
  84. package/.grok/skills/lit-pptx/templates/boilerplate_professional.css +6 -6
  85. package/.grok/skills/lit-pptx/templates/enrolled/AZURE-A2Z/layout-mapping.yaml +1 -1
  86. package/.grok/skills/lit-pptx/templates/enrolled/AZURE-A2Z/template.yaml +4 -1
  87. package/.grok/skills/lit-pptx/templates/enrolled/AZURE-PRO/layout-mapping.yaml +1 -1
  88. package/.grok/skills/lit-pptx/templates/enrolled/AZURE-PRO/template.yaml +4 -1
  89. package/.grok/skills/lit-pptx/templates/tonalities/atlas/pack.yaml +99 -0
  90. package/.grok/skills/lit-pptx/templates/tonalities/chalk/pack.yaml +104 -0
  91. package/.grok/skills/lit-pptx/templates/tonalities/gazette/pack.yaml +99 -0
  92. package/.grok/skills/lit-pptx/templates/tonalities/ledger/pack.yaml +100 -0
  93. package/.grok/skills/lit-pptx/templates/tonalities/night/pack.yaml +100 -0
  94. package/.grok/skills/lit-pptx/templates/tonalities/paper/pack.yaml +105 -0
  95. package/.grok/skills/lit-pptx/templates/tonalities/signal/pack.yaml +101 -0
  96. package/.grok/skills/lit-pptx/templates/tonalities/studio/pack.yaml +99 -0
  97. package/.grok/skills/lit-typographic-motion/THIRD_PARTY_NOTICES +1 -1
  98. package/.grok/skills/lit-typographic-motion/fonts/pins.json +6 -6
  99. package/.grok/skills/lit-typographic-motion/references/runtime.md +1 -1
  100. package/.grok/skills/lit-typographic-motion/references/style-bible.md +2 -2
  101. package/.grok/skills/lit-typographic-motion/references/type-craft.md +2 -2
  102. package/.grok/skills/lit-typographic-motion/scripts/lib/gate.mjs +1 -1
  103. package/.grok/skills/litgrok/SKILL.md +1 -1
  104. package/.grok-plugin/plugin.json +1 -1
  105. package/CHANGELOG.md +11 -0
  106. package/README.md +9 -9
  107. package/README_ko-KR.md +9 -9
  108. package/docs/assets/readme/badge-version.svg +1 -1
  109. package/docs/reference.md +1 -1
  110. package/docs/reference_ko-KR.md +1 -1
  111. package/package.json +1 -1
  112. package/plugin.json +1 -1
  113. package/.grok/skills/lit-pptx/pretendard-font/public/static/PretendardGOV-Bold.otf +0 -0
  114. package/.grok/skills/lit-pptx/pretendard-font/public/static/PretendardGOV-Regular.otf +0 -0
@@ -17,10 +17,17 @@ 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
28
+
23
29
  import argparse
30
+ import copy
24
31
  import re
25
32
  import sys
26
33
  from pathlib import Path
@@ -41,6 +48,7 @@ try:
41
48
  from docx.enum.style import WD_STYLE_TYPE
42
49
  from docx.oxml.ns import qn
43
50
  from docx.oxml import OxmlElement
51
+ from docx.text.paragraph import Paragraph
44
52
  except ImportError:
45
53
  print("Error: 'python-docx' library not found. Install with: pip install python-docx")
46
54
  sys.exit(1)
@@ -51,6 +59,8 @@ except ImportError:
51
59
  print("Error: 'beautifulsoup4' library not found. Install with: pip install beautifulsoup4")
52
60
  sys.exit(1)
53
61
 
62
+ import docx_design # noqa: E402 (needs the runtime's python-docx and PyYAML)
63
+
54
64
 
55
65
  # ===========================================================================
56
66
  # Legacy HTML→DOCX helpers (unchanged from pre-M1 — preserved byte-for-byte)
@@ -153,10 +163,16 @@ def process_list(doc: Document, element, md_path: Path, ordered: bool = False, l
153
163
  if level > 0:
154
164
  para.paragraph_format.left_indent = Inches(0.5 * level)
155
165
 
156
- # Process direct text content
157
- for child in li.children:
166
+ # Process direct text content. Only the item's own edges are trimmed: the space after a bold lead
167
+ # ("**1,200개** 태그") belongs to the text.
168
+ children = list(li.children)
169
+ for ci, child in enumerate(children):
158
170
  if isinstance(child, NavigableString):
159
- text = str(child).strip()
171
+ text = str(child).replace("\n", " ")
172
+ if ci == 0 or getattr(children[ci - 1], "name", None) in ('ul', 'ol', 'p'):
173
+ text = text.lstrip()
174
+ if ci == len(children) - 1 or getattr(children[ci + 1], "name", None) in ('ul', 'ol', 'p'):
175
+ text = text.rstrip()
160
176
  if text:
161
177
  para.add_run(text)
162
178
  elif child.name in ('ul', 'ol'):
@@ -185,21 +201,12 @@ def process_table(doc: Document, element, md_path: Path):
185
201
 
186
202
  table = doc.add_table(rows=len(rows), cols=cols)
187
203
  table.style = 'Table Grid'
188
- table.autofit = False
189
- section = doc.sections[-1]
190
- available = section.page_width - section.left_margin - section.right_margin
191
- texts = [[cell.get_text(' ', strip=True) for cell in tr.find_all(['th', 'td'])[:cols]] for tr in rows]
192
- weights = [max(8, min(35, max((len(row[i]) for row in texts if len(row) > i), default=8))) for i in range(cols)]
193
- widths = [int(available * weight / sum(weights)) for weight in weights]
194
- for column, width in zip(table.columns, widths):
195
- column.width = width
196
204
 
197
205
  for row_idx, tr in enumerate(rows):
198
206
  cells = tr.find_all(['th', 'td'])
199
207
  for col_idx, cell in enumerate(cells):
200
208
  if col_idx < cols:
201
209
  table_cell = table.rows[row_idx].cells[col_idx]
202
- table_cell.width = widths[col_idx]
203
210
  # Clear default paragraph
204
211
  table_cell.text = ''
205
212
  para = table_cell.paragraphs[0]
@@ -210,13 +217,76 @@ def process_table(doc: Document, element, md_path: Path):
210
217
  if cell.name == 'th':
211
218
  for run in para.runs:
212
219
  run.bold = True
213
- tr_pr = table.rows[row_idx]._tr.get_or_add_trPr()
214
- no_split = OxmlElement('w:cantSplit')
215
- tr_pr.append(no_split)
216
- if row_idx == 0:
217
- repeat = OxmlElement('w:tblHeader')
218
- repeat.set(qn('w:val'), 'true')
219
- tr_pr.append(repeat)
220
+
221
+ shape_table(doc, table, [[c.get_text(" ", strip=True) for c in tr.find_all(['th', 'td'])] for tr in rows])
222
+
223
+
224
+ # A cell reads as a number when, after signs and units, only digits remain.
225
+ NUMERIC_CELL = re.compile(r"^[+\-−±▲▼△▽]?\s*[\d.,]+\s*(?:%p?|배|x|pt|[가-힣]{1,3}|[A-Za-z]{1,3})?$")
226
+
227
+
228
+ def shape_table(doc, table, texts: list[list[str]]) -> None:
229
+ """Give a table readable columns and keep it whole on the page.
230
+
231
+ Column widths follow the longest cell of each column (Hangul counted wider)
232
+ with a floor, so a short column never squeezes a long one into one word per
233
+ line. The header row repeats on a new page, a row never splits across pages,
234
+ and columns of numbers are right-aligned.
235
+ """
236
+ if not texts or not texts[0]:
237
+ return
238
+ cols = len(table.columns)
239
+ section = doc.sections[-1]
240
+ usable = section.page_width - section.left_margin - section.right_margin
241
+
242
+ def width_of(text: str) -> float:
243
+ # Hangul runs a little over twice as wide as Latin; +3 covers the cell padding.
244
+ return sum(2.3 if "\uac00" <= ch <= "\ud7a3" else 1.0 for ch in text) + 3.0
245
+
246
+ want = []
247
+ for ci in range(cols):
248
+ column = [row[ci] for row in texts if ci < len(row)]
249
+ want.append(max([width_of(t) for t in column] + [4.0]))
250
+ # Each column first gets its natural width (about 0.085 in per Latin character at
251
+ # body size, capped at 45% of the line); columns that hit the cap share what is left.
252
+ unit = Inches(0.085)
253
+ cap = usable * 0.45
254
+ natural = [min(w * unit, cap) for w in want]
255
+ capped = [ci for ci in range(cols) if want[ci] * unit > cap]
256
+ spare = usable - sum(natural)
257
+ if spare > 0 and capped:
258
+ for ci in capped:
259
+ natural[ci] += spare / len(capped)
260
+ elif spare > 0:
261
+ natural = [w + spare / cols for w in natural]
262
+ scale = usable / sum(natural)
263
+ widths = [int(w * scale) for w in natural]
264
+
265
+ table.autofit = False
266
+ for ci, column in enumerate(table.columns):
267
+ column.width = widths[ci]
268
+ for ri, row in enumerate(table.rows):
269
+ tr_pr = row._tr.get_or_add_trPr()
270
+ # Schema order puts cantSplit before trHeight and tblHeader after it.
271
+ tr_pr.insert(0, OxmlElement("w:cantSplit"))
272
+ if ri == 0:
273
+ header = OxmlElement("w:tblHeader")
274
+ tr_pr.append(header)
275
+ for ci, cell in enumerate(row.cells):
276
+ if ci < cols:
277
+ cell.width = widths[ci]
278
+ for paragraph in cell.paragraphs:
279
+ # Body line spacing (1.6 in some profiles) makes every row a double row.
280
+ paragraph.paragraph_format.line_spacing = 1.15
281
+ paragraph.paragraph_format.space_after = Pt(0)
282
+ paragraph.paragraph_format.first_line_indent = Cm(0)
283
+
284
+ for ci in range(cols):
285
+ data = [row[ci] for row in texts[1:] if ci < len(row) and row[ci].strip()]
286
+ if data and all(NUMERIC_CELL.match(t.strip()) for t in data):
287
+ for row in table.rows:
288
+ for paragraph in row.cells[ci].paragraphs:
289
+ paragraph.alignment = WD_ALIGN_PARAGRAPH.RIGHT
220
290
 
221
291
 
222
292
  def process_code_block(doc: Document, element):
@@ -235,6 +305,26 @@ def process_code_block(doc: Document, element):
235
305
  para._p.get_or_add_pPr().append(shading)
236
306
 
237
307
 
308
+ def add_figure_caption(doc, element, para) -> None:
309
+ """An image alone in its paragraph keeps its alt text as the caption under it
310
+ ("Figure 1." / "그림 1." bold), and the image keeps with the caption."""
311
+ images = element.find_all('img')
312
+ alt = images[0].get('alt', '').strip() if len(images) == 1 else ''
313
+ if not alt or element.get_text(strip=True):
314
+ return
315
+ para.paragraph_format.keep_with_next = True
316
+ try:
317
+ caption = doc.add_paragraph(style='Caption')
318
+ except KeyError:
319
+ caption = doc.add_paragraph()
320
+ label = docx_design.FIGURE_LABEL.match(alt)
321
+ if label:
322
+ caption.add_run(label.group(1)).bold = True
323
+ caption.add_run(alt[len(label.group(1)):])
324
+ else:
325
+ caption.add_run(alt)
326
+
327
+
238
328
  def html_to_docx(html: str, doc: Document, md_path: Path):
239
329
  """Convert HTML to DOCX elements."""
240
330
  soup = BeautifulSoup(html, 'html.parser')
@@ -253,7 +343,8 @@ def html_to_docx(html: str, doc: Document, md_path: Path):
253
343
  process_inline_elements(heading, child, md_path)
254
344
 
255
345
  elif element.name == 'p':
256
- add_paragraph_with_formatting(doc, element, md_path)
346
+ para = add_paragraph_with_formatting(doc, element, md_path)
347
+ add_figure_caption(doc, element, para)
257
348
 
258
349
  elif element.name == 'ul':
259
350
  process_list(doc, element, md_path, ordered=False)
@@ -626,6 +717,111 @@ def apply_table_style(doc, design: dict) -> None:
626
717
  existing_bottom.set(qn("w:color"), "000000")
627
718
 
628
719
 
720
+ def keep_tables_with_captions(doc) -> None:
721
+ """A table caption keeps with its table, and a table that fits one page keeps its rows together
722
+ (every row but the last keeps with the next). Pagination only: text, styles and rules are unchanged."""
723
+ section = doc.sections[-1]
724
+ frame_h = (section.page_height - section.top_margin - section.bottom_margin) / 12700
725
+ for table in doc.tables:
726
+ prev = table._tbl.getprevious()
727
+ 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()):
728
+ Paragraph(prev, table._parent).paragraph_format.keep_with_next = True
729
+ widths = [c.width / 12700 if c.width else 100 for c in table.rows[0].cells]
730
+ 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)
731
+ if est <= 0.9 * frame_h:
732
+ for row in table.rows[:-1]:
733
+ for cell in row.cells:
734
+ cell.paragraphs[0].paragraph_format.keep_with_next = True
735
+
736
+
737
+ def publisher_spacing(doc) -> None:
738
+ """Text never touches a table or a caption: the paragraph after a table stands three quarters of a body line
739
+ clear (8 pt read as half a line), a table caption after text 8 pt, and a figure caption keeps 8 pt under it.
740
+ A paragraph after a list stands 6 pt clear of its last item. A heading keeps its own, larger space above."""
741
+ body = doc.element.body
742
+ normal = doc.styles["Normal"]
743
+ size = normal.font.size.pt if normal.font.size else 11
744
+ multiple = normal.paragraph_format.line_spacing if isinstance(normal.paragraph_format.line_spacing, float) else 1.15
745
+ clear = Pt(max(8, round(size * multiple * 1.2 * 0.75)))
746
+ for el in body.iterchildren(qn("w:tbl")):
747
+ nxt = el.getnext()
748
+ 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():
749
+ para = Paragraph(nxt, doc._body)
750
+ own = para.style.paragraph_format.space_before if para.style is not None else None
751
+ fmt = para.paragraph_format
752
+ if not (own and own >= clear) and (not fmt.space_before or fmt.space_before < clear):
753
+ fmt.space_before = clear
754
+ prev = el.getprevious()
755
+ 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()):
756
+ fmt = Paragraph(prev, doc._body).paragraph_format
757
+ if not fmt.space_before or fmt.space_before < Pt(8):
758
+ fmt.space_before = Pt(8)
759
+ docx_design.space_after_lists(doc)
760
+ for p in doc.paragraphs:
761
+ prev = p._p.getprevious()
762
+ under_picture = prev is not None and prev.find(f".//{qn('w:drawing')}") is not None
763
+ if docx_design.FIGURE_LABEL.match(p.text.strip()) and (under_picture or (p.style is not None and p.style.name == "Caption")):
764
+ if not p.paragraph_format.space_after or p.paragraph_format.space_after < Pt(8):
765
+ p.paragraph_format.space_after = Pt(8)
766
+
767
+
768
+ def plain_table_cells(doc) -> None:
769
+ """Table cells neither hyphenate nor justify: the body may (the profile's hyphenation), but a narrow cell
770
+ broke "Pro-posed" and a justified header spread "Conversion at"."""
771
+ for table in doc.tables:
772
+ for row in table.rows:
773
+ for cell in row.cells:
774
+ for paragraph in cell.paragraphs:
775
+ p_pr = paragraph._p.get_or_add_pPr()
776
+ if p_pr.find(qn("w:suppressAutoHyphens")) is None:
777
+ p_pr.insert_element_before(OxmlElement("w:suppressAutoHyphens"), *SUPPRESS_AFTER)
778
+ if paragraph.alignment in (None, WD_ALIGN_PARAGRAPH.JUSTIFY):
779
+ paragraph.alignment = WD_ALIGN_PARAGRAPH.LEFT
780
+
781
+
782
+ SUPPRESS_AFTER = ("w:kinsoku", "w:wordWrap", "w:overflowPunct", "w:topLinePunct", "w:autoSpaceDE", "w:autoSpaceDN", "w:bidi",
783
+ "w:adjustRightInd", "w:snapToGrid", "w:spacing", "w:ind", "w:contextualSpacing", "w:mirrorIndents",
784
+ "w:suppressOverlap", "w:jc", "w:textDirection", "w:textAlignment", "w:textboxTightWrap",
785
+ "w:outlineLvl", "w:divId", "w:cnfStyle", "w:rPr", "w:sectPr", "w:pPrChange")
786
+
787
+
788
+ def page_numbers(doc, design: dict) -> None:
789
+ """The profile's page numbering (registry design.header_footer): a centred PAGE field in the footer at the
790
+ footer size, and none on the first page when the profile says so (its header keeps the notice tag)."""
791
+ hf = design.get("header_footer") or {}
792
+ if hf.get("page_numbering") != "bottom_center":
793
+ return
794
+ size = Pt(float(hf.get("footer_font_size_pt") or 9))
795
+ for index, section in enumerate(doc.sections):
796
+ if index and section.footer.is_linked_to_previous:
797
+ continue
798
+ if not hf.get("page_numbering_first_page", True) and not section.different_first_page_header_footer:
799
+ section.different_first_page_header_footer = True
800
+ first = section.first_page_header
801
+ for paragraph in section.header.paragraphs:
802
+ first._element.append(copy.deepcopy(paragraph._p))
803
+ first._element.remove(first.paragraphs[0]._p)
804
+ footer = section.footer
805
+ paragraph = footer.paragraphs[0]
806
+ paragraph.alignment = WD_ALIGN_PARAGRAPH.CENTER
807
+ run = paragraph.add_run()
808
+ run.font.size = size
809
+ for kind, text in (("begin", None), (None, " PAGE "), ("separate", None), (None, "1"), ("end", None)):
810
+ if kind:
811
+ char = OxmlElement("w:fldChar")
812
+ char.set(qn("w:fldCharType"), kind)
813
+ run._r.append(char)
814
+ elif text == " PAGE ":
815
+ instr = OxmlElement("w:instrText")
816
+ instr.set(qn("xml:space"), "preserve")
817
+ instr.text = text
818
+ run._r.append(instr)
819
+ else:
820
+ t = OxmlElement("w:t")
821
+ t.text = text
822
+ run._r.append(t)
823
+
824
+
629
825
  def strip_banned(doc, design: dict) -> None:
630
826
  """Remove any banned font/style from runs and paragraphs."""
631
827
  banned = design.get("banned") or {}
@@ -670,6 +866,19 @@ def _set_run_fonts(run, latin: str, cjk: str | None) -> None:
670
866
  rfonts.set(qn("w:eastAsia"), cjk)
671
867
 
672
868
 
869
+ THEME_PAIRS = (("asciiTheme", "ascii"), ("hAnsiTheme", "hAnsi"), ("eastAsiaTheme", "eastAsia"), ("cstheme", "cs"))
870
+
871
+
872
+ def drop_shadowed_theme_fonts(doc) -> None:
873
+ """A style that names its face keeps no theme font beside it: Word and LibreOffice both let the theme
874
+ attribute win, so the profile's heading face (Times New Roman, Arial, Pretendard) rendered as the
875
+ theme's Calibri. Styles only; the document body is untouched."""
876
+ for rfonts in doc.styles.element.iter(qn("w:rFonts")):
877
+ for theme, named in THEME_PAIRS:
878
+ if rfonts.get(qn(f"w:{named}")) and rfonts.get(qn(f"w:{theme}")) is not None:
879
+ del rfonts.attrib[qn(f"w:{theme}")]
880
+
881
+
673
882
  def apply_cjk_font_pairing(doc, font_cfg: dict) -> None:
674
883
  body = font_cfg.get("body") or {}
675
884
  body_cjk = font_cfg.get("body_cjk") or {}
@@ -758,6 +967,17 @@ def inject_title_block(doc, frontmatter: dict, design: dict) -> None:
758
967
  )
759
968
  _add_run(tp, str(title), tb.get("title", {}).get("size_pt", 18), bold=True)
760
969
 
970
+ # Report-style frontmatter: a subtitle and one author / organisation / date line
971
+ # when there is no journal author list.
972
+ if frontmatter.get("subtitle"):
973
+ sp = _p_center(13, space_after=6)
974
+ _add_run(sp, str(frontmatter["subtitle"]), 13)
975
+ if not frontmatter.get("authors"):
976
+ meta = [str(v) for v in (frontmatter.get("author"), frontmatter.get("organization"), frontmatter.get("date")) if v]
977
+ if meta:
978
+ mp = _p_center(10.5, space_after=14)
979
+ _add_run(mp, " · ".join(meta), 10.5)
980
+
761
981
  # Authors
762
982
  authors = frontmatter.get("authors") or []
763
983
  if authors:
@@ -872,6 +1092,73 @@ def detect_locale(text: str) -> str:
872
1092
  # ===========================================================================
873
1093
 
874
1094
 
1095
+ NOTICE_INK, NOTICE_FILL = docx_design.NOTICE
1096
+
1097
+
1098
+ def _shade(element, fill: str) -> None:
1099
+ shd = OxmlElement("w:shd")
1100
+ shd.set(qn("w:val"), "clear")
1101
+ shd.set(qn("w:color"), "auto")
1102
+ shd.set(qn("w:fill"), fill)
1103
+ element.append(shd)
1104
+
1105
+
1106
+ def add_notice(doc, text: str) -> None:
1107
+ """The frontmatter `notice:` (e.g. "예시 데이터: 수치는 가정입니다") as a tag in
1108
+ every page header, so an example document cannot be mistaken for a sourced one
1109
+ on any page it is printed from. A profile whose title page has its own, empty
1110
+ header (korean-generic) gets a tinted band under the title block instead."""
1111
+ if doc.sections[0].different_first_page_header_footer:
1112
+ band = doc.add_paragraph()
1113
+ band_run = band.add_run(str(text))
1114
+ band_run.bold = True
1115
+ band_run.font.size = Pt(10.5)
1116
+ band_run.font.color.rgb = RGBColor.from_string(NOTICE_INK)
1117
+ band.paragraph_format.space_before = Pt(4)
1118
+ band.paragraph_format.space_after = Pt(14)
1119
+ _shade(band._p.get_or_add_pPr(), NOTICE_FILL)
1120
+ for index, section in enumerate(doc.sections):
1121
+ if index and section.header.is_linked_to_previous:
1122
+ continue
1123
+ header = section.header
1124
+ tag = header.paragraphs[0] if header.paragraphs and not header.paragraphs[0].text.strip() else header.add_paragraph()
1125
+ tag_run = tag.add_run(f"\u00a0{text}\u00a0")
1126
+ tag_run.bold = True
1127
+ tag_run.font.size = Pt(9)
1128
+ tag_run.font.color.rgb = RGBColor.from_string(NOTICE_INK)
1129
+ _shade(tag_run._r.get_or_add_rPr(), NOTICE_FILL)
1130
+
1131
+
1132
+ def read_source(md_path: Path) -> str:
1133
+ """The Markdown source as written; numbers in it are the author's own."""
1134
+ return md_path.read_text(encoding="utf-8")
1135
+
1136
+
1137
+ MD_EXTENSIONS = ['tables', 'fenced_code', 'nl2br', 'sane_lists']
1138
+
1139
+
1140
+ def _md_renderer(md_path: Path):
1141
+ """Markdown into a document or a component's cell, through the legacy HTML helpers."""
1142
+ def render(container, text: str) -> None:
1143
+ html_to_docx(markdown.Markdown(extensions=MD_EXTENSIONS).convert(text), container, md_path)
1144
+ return render
1145
+
1146
+
1147
+ def _typography(design: dict, lang: str = "en"):
1148
+ """The registry's micro-typography filters, for Markdown outside the directive fences; Korean text also
1149
+ writes ISO dates the Korean way (2026. 10. 1.)."""
1150
+ def apply(text: str) -> str:
1151
+ # Dates first: the dash filter would read 2026-06-30 as a number range.
1152
+ if lang != "en":
1153
+ text = docx_design.korean_dates(text)
1154
+ text = fix_dashes(text, design.get("micro_typography", {}).get("dashes"))
1155
+ text = fix_quotes(text)
1156
+ text = fix_unit_spacing(text)
1157
+ text = fix_ellipsis(text)
1158
+ return normalize_double_spaces(text)
1159
+ return apply
1160
+
1161
+
875
1162
  def convert_md_to_docx(
876
1163
  md_path: Path,
877
1164
  docx_path: Path,
@@ -879,33 +1166,43 @@ def convert_md_to_docx(
879
1166
  publisher_name: str | None = None,
880
1167
  registry_path: Path | None = None,
881
1168
  locale: str = "auto",
1169
+ tonality: str | None = None,
1170
+ density: int | None = None,
1171
+ variance: int | None = None,
882
1172
  ):
883
1173
  """Convert Markdown file to DOCX.
884
1174
 
885
- When publisher_name is None, behavior is byte-compatible with the pre-M1
886
- script: read md, convert to HTML, html_to_docx, save. Regression-guarded.
1175
+ With a tonality (argument or frontmatter `tonality:`), the document is built
1176
+ by docx_design in that design direction. Without a tonality or a publisher,
1177
+ the plain profile runs through the same builders with neutral tokens (A4,
1178
+ Pretendard, booktabs tables, notice in every header).
887
1179
 
888
1180
  When publisher_name is set, apply the full journal pipeline: registry
889
1181
  load, frontmatter parse, pre-MD filters, title-block injection, post-HTML
890
1182
  heading numbering, booktabs tables, banned stripping, CJK font pairing.
1183
+ A publisher run writes the same document.xml as before the tonality
1184
+ engine unless the source holds figures (captions) or directives.
891
1185
  """
892
1186
  if publisher_name is None:
893
- # --- Legacy code path (regression-critical: do not mutate) ---
894
- md_text = md_path.read_text(encoding='utf-8')
895
- md_converter = markdown.Markdown(extensions=[
896
- 'tables',
897
- 'fenced_code',
898
- 'nl2br',
899
- 'sane_lists',
900
- ])
901
- html = md_converter.convert(md_text)
902
- if template_path and template_path.exists():
903
- doc = Document(str(template_path))
904
- else:
905
- doc = Document()
906
- html_to_docx(html, doc, md_path)
1187
+ md_text = read_source(md_path)
1188
+ front, body = parse_frontmatter(md_text) if md_text.startswith("---") else ({}, md_text)
1189
+ name = tonality or front.get("tonality")
1190
+ lang = "en" if (detect_locale(body) if locale == "auto" else locale) == "en" else "ko"
1191
+ dials = {k: v for k, v in (("density", density if density is not None else front.get("density")),
1192
+ ("variance", variance if variance is not None else front.get("variance"))) if v is not None}
1193
+ try:
1194
+ if name:
1195
+ T = docx_design.load_tonality(name, locale=lang, **{k: int(v) for k, v in dials.items()})
1196
+ filters = _typography(T.design, lang)
1197
+ else:
1198
+ T, filters = docx_design.neutral_tonality(lang), None
1199
+ doc = Document(str(template_path)) if template_path and template_path.exists() else Document()
1200
+ docx_design.build(doc, front, body, T, _md_renderer(md_path), lang, filters)
1201
+ except docx_design.DesignError as exc:
1202
+ raise SystemExit(f"Error: {exc}") from exc
907
1203
  doc.save(str(docx_path))
908
- print(f"Successfully converted {md_path} to {docx_path}")
1204
+ label = f"tonality={T.name}, density={T.density}, variance={T.variance}" if not T.neutral else "plain profile"
1205
+ print(f"Successfully converted {md_path} to {docx_path} [{label}, locale={lang}]")
909
1206
  return
910
1207
 
911
1208
  # --- Journal workflow ---
@@ -915,19 +1212,28 @@ def convert_md_to_docx(
915
1212
  design = publisher["design"]
916
1213
  docx_font = publisher["docx"]["font"]
917
1214
 
918
- md_text = md_path.read_text(encoding="utf-8")
1215
+ md_text = read_source(md_path)
919
1216
  frontmatter, body = parse_frontmatter(md_text)
1217
+ if frontmatter.get("tonality"):
1218
+ raise SystemExit(f"Error: frontmatter tonality: {frontmatter['tonality']} and --publisher {publisher_name} are mutually exclusive")
920
1219
 
921
1220
  # Locale detection (informational in M1; Korean rules land in M6)
922
1221
  detected_locale = detect_locale(body) if locale == "auto" else locale
923
1222
 
924
1223
  # Pre-MD micro-typography filters
1224
+ if detected_locale in ("ko", "mixed"):
1225
+ # Every path: no ISO dates in Korean text. Before the dash filter, which would
1226
+ # read 2026-06-30 as a number range.
1227
+ body = docx_design.korean_dates(body)
1228
+ if frontmatter.get("date"):
1229
+ frontmatter["date"] = docx_design.format_date(frontmatter["date"], "ko")
925
1230
  dash_rules = design.get("micro_typography", {}).get("dashes")
926
- body = fix_dashes(body, dash_rules)
927
- body = fix_quotes(body)
928
- body = fix_unit_spacing(body)
929
- body = fix_ellipsis(body)
930
- body = normalize_double_spaces(body)
1231
+
1232
+ def typeset(text: str) -> str:
1233
+ return normalize_double_spaces(fix_ellipsis(fix_unit_spacing(fix_quotes(fix_dashes(text, dash_rules)))))
1234
+
1235
+ # A ::: fence line carries attributes (title="…"); curly quotes there would break the directive.
1236
+ body = "\n".join(line if re.match(r"\s*:{3,}", line) else typeset(line) for line in body.split("\n"))
931
1237
 
932
1238
  # Load template if available
933
1239
  template_docx = script_dir.parent / "templates" / "docx" / f"{publisher_name}.docx"
@@ -952,22 +1258,46 @@ def convert_md_to_docx(
952
1258
  # Title block (built from frontmatter) before main content
953
1259
  if frontmatter.get("title"):
954
1260
  inject_title_block(doc, frontmatter, design)
1261
+ if frontmatter.get("notice"):
1262
+ add_notice(doc, frontmatter["notice"])
1263
+ page_numbers(doc, design)
955
1264
 
956
1265
  # Main content
957
- md_converter = markdown.Markdown(extensions=[
958
- 'tables',
959
- 'fenced_code',
960
- 'nl2br',
961
- 'sane_lists',
962
- ])
963
- html = md_converter.convert(body)
964
- html_to_docx(html, doc, md_path)
1266
+ if docx_design.has_directives(body):
1267
+ # Components in a journal manuscript are drawn with neutral styling (ink and line only).
1268
+ neutral = docx_design.neutral_tonality("en" if detected_locale == "en" else "ko")
1269
+ docx_design.setup_component_styles(doc, neutral)
1270
+ ctx = docx_design.Build(doc, neutral, _md_renderer(md_path), "en" if detected_locale == "en" else "ko")
1271
+ try:
1272
+ ctx.render(doc, body)
1273
+ except docx_design.DesignError as exc:
1274
+ raise SystemExit(f"Error: {exc}") from exc
1275
+ print("Note: the ::: components are drawn with publisher-neutral styling under a publisher profile")
1276
+ else:
1277
+ md_converter = markdown.Markdown(extensions=[
1278
+ 'tables',
1279
+ 'fenced_code',
1280
+ 'nl2br',
1281
+ 'sane_lists',
1282
+ ])
1283
+ html = md_converter.convert(body)
1284
+ html_to_docx(html, doc, md_path)
965
1285
 
966
1286
  # Post-HTML design enforcement
967
1287
  apply_heading_numbering(doc, design)
968
1288
  apply_table_style(doc, design)
1289
+ keep_tables_with_captions(doc)
1290
+ plain_table_cells(doc)
1291
+ publisher_spacing(doc)
1292
+ # No column narrower than its longest word ("Cataly/st" in a publisher table).
1293
+ normal = doc.styles["Normal"].font.size
1294
+ for table in doc.tables:
1295
+ docx_design.fit_word_widths(table, normal.pt if normal else 11)
1296
+ docx_design.restart_numbered_lists(doc)
1297
+ docx_design.keep_short_lists(doc)
969
1298
  strip_banned(doc, design)
970
1299
  apply_cjk_font_pairing(doc, docx_font)
1300
+ drop_shadowed_theme_fonts(doc)
971
1301
 
972
1302
  doc.save(str(docx_path))
973
1303
  print(
@@ -997,6 +1327,14 @@ def parse_args() -> argparse.Namespace:
997
1327
  help="Path to templates/registry.yaml (defaults to the one in this skill)",
998
1328
  default=None,
999
1329
  )
1330
+ parser.add_argument(
1331
+ "--tonality",
1332
+ help="Design direction from templates/tonalities/: Report, Brief, Manual, Proposal, Memo or Journal "
1333
+ "(frontmatter tonality: works too)",
1334
+ default=None,
1335
+ )
1336
+ parser.add_argument("--density", type=int, default=None, help="Density dial 1-10 (default: the pack's)")
1337
+ parser.add_argument("--variance", type=int, default=None, help="Variance dial 1-10 (default: the pack's)")
1000
1338
  parser.add_argument(
1001
1339
  "--locale",
1002
1340
  choices=("auto", "en", "ko", "mixed"),
@@ -1006,6 +1344,8 @@ def parse_args() -> argparse.Namespace:
1006
1344
  args = parser.parse_args()
1007
1345
  if args.publisher and args.template:
1008
1346
  parser.error("--publisher and --template are mutually exclusive")
1347
+ if args.publisher and args.tonality:
1348
+ parser.error("--tonality and --publisher are mutually exclusive: a tonality is a design direction, a publisher a journal profile")
1009
1349
  return args
1010
1350
 
1011
1351
 
@@ -1026,6 +1366,9 @@ def main() -> int:
1026
1366
  publisher_name=args.publisher,
1027
1367
  registry_path=registry_path,
1028
1368
  locale=args.locale,
1369
+ tonality=args.tonality,
1370
+ density=args.density,
1371
+ variance=args.variance,
1029
1372
  )
1030
1373
  return 0
1031
1374