@litfamily/litopencode 1.0.16 → 1.0.18

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 (119) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/README-Ko-KR.md +11 -11
  3. package/README.md +11 -11
  4. package/dist/activation-routing.js +1 -1
  5. package/dist/deliverable-hedge-guard.js +3 -1
  6. package/dist/features.js +2 -2
  7. package/dist/hooks.js +2 -1
  8. package/dist/skills.js +6 -4
  9. package/docs/assets/readme/badge-version.svg +1 -1
  10. package/docs/reference-Ko-KR.md +1 -1
  11. package/package.json +1 -1
  12. package/skills/frontend-ui-ux/data/THIRD-PARTY-NOTICE.txt +1 -3
  13. package/skills/lit-docx/SKILL.md +80 -19
  14. package/skills/lit-docx/references/components.md +253 -0
  15. package/skills/lit-docx/references/direction-step.md +234 -0
  16. package/skills/lit-docx/references/examples/01-report-ko.md +160 -0
  17. package/skills/lit-docx/references/examples/02-brief-ko.md +58 -0
  18. package/skills/lit-docx/references/examples/03-manual-ko.md +107 -0
  19. package/skills/lit-docx/references/examples/04-proposal-ko.md +93 -0
  20. package/skills/lit-docx/references/examples/05-memo-ko.md +42 -0
  21. package/skills/lit-docx/references/examples/06-journal-en.md +84 -0
  22. package/skills/lit-docx/references/examples/07-report-en.md +137 -0
  23. package/skills/lit-docx/references/examples/08-journal-ko.md +93 -0
  24. package/skills/lit-docx/references/examples/09-brief-en.md +64 -0
  25. package/skills/lit-docx/references/page-composition.md +306 -0
  26. package/skills/lit-docx/references/tonalities/brief.md +104 -0
  27. package/skills/lit-docx/references/tonalities/journal.md +101 -0
  28. package/skills/lit-docx/references/tonalities/manual.md +111 -0
  29. package/skills/lit-docx/references/tonalities/memo.md +105 -0
  30. package/skills/lit-docx/references/tonalities/proposal.md +101 -0
  31. package/skills/lit-docx/references/tonalities/report.md +126 -0
  32. package/skills/lit-docx/run.py +2 -1
  33. package/skills/lit-docx/scripts/convert_md_to_docx.py +384 -67
  34. package/skills/lit-docx/scripts/craft_docx.py +204 -1
  35. package/skills/lit-docx/scripts/docx_design.py +2445 -0
  36. package/skills/lit-docx/scripts/docx_layout.py +959 -0
  37. package/skills/lit-docx/scripts/slop_lint.py +23 -6
  38. package/skills/lit-docx/templates/tonalities/brief.yaml +28 -0
  39. package/skills/lit-docx/templates/tonalities/journal.yaml +25 -0
  40. package/skills/lit-docx/templates/tonalities/manual.yaml +26 -0
  41. package/skills/lit-docx/templates/tonalities/memo.yaml +26 -0
  42. package/skills/lit-docx/templates/tonalities/proposal.yaml +26 -0
  43. package/skills/lit-docx/templates/tonalities/report.yaml +26 -0
  44. package/skills/lit-pptx/NOTICE.md +1 -1
  45. package/skills/lit-pptx/SKILL.md +108 -19
  46. package/skills/lit-pptx/pretendard-font/public/static/Pretendard-Bold.otf +0 -0
  47. package/skills/lit-pptx/pretendard-font/public/static/Pretendard-Regular.otf +0 -0
  48. package/skills/lit-pptx/references/density-and-fill.md +283 -0
  49. package/skills/lit-pptx/references/direction-step.md +249 -0
  50. package/skills/lit-pptx/references/examples/01-ops-review-ledger-ko.md +173 -0
  51. package/skills/lit-pptx/references/examples/02-launch-pitch-signal-en.md +153 -0
  52. package/skills/lit-pptx/references/examples/03-field-survey-atlas-ko.md +120 -0
  53. package/skills/lit-pptx/references/examples/04-lecture-chalk-ko.md +132 -0
  54. package/skills/lit-pptx/references/examples/05-research-talk-paper-en.md +162 -0
  55. package/skills/lit-pptx/references/examples/06-policy-briefing-gazette-ko.md +176 -0
  56. package/skills/lit-pptx/references/examples/07-annual-story-studio-en.md +131 -0
  57. package/skills/lit-pptx/references/examples/08-reliability-keynote-night-en.md +182 -0
  58. package/skills/lit-pptx/references/examples/09-status-update-ledger-en.md +147 -0
  59. package/skills/lit-pptx/references/examples/10-investor-pitch-signal-ko.md +136 -0
  60. package/skills/lit-pptx/references/examples/11-forecast-review-night-ko.md +177 -0
  61. package/skills/lit-pptx/references/examples/12-exhibition-proposal-studio-ko.md +149 -0
  62. package/skills/lit-pptx/references/layout-families.md +470 -0
  63. package/skills/lit-pptx/references/title-treatments.md +271 -0
  64. package/skills/lit-pptx/references/tonalities/atlas.md +131 -0
  65. package/skills/lit-pptx/references/tonalities/chalk.md +132 -0
  66. package/skills/lit-pptx/references/tonalities/gazette.md +137 -0
  67. package/skills/lit-pptx/references/tonalities/ledger.md +150 -0
  68. package/skills/lit-pptx/references/tonalities/night.md +148 -0
  69. package/skills/lit-pptx/references/tonalities/paper.md +135 -0
  70. package/skills/lit-pptx/references/tonalities/signal.md +147 -0
  71. package/skills/lit-pptx/references/tonalities/studio.md +149 -0
  72. package/skills/lit-pptx/scripts/compile-deck.js +68 -21
  73. package/skills/lit-pptx/scripts/craft-thresholds.json +45 -1
  74. package/skills/lit-pptx/scripts/craft_extras.py +37 -8
  75. package/skills/lit-pptx/scripts/deck_output.py +654 -0
  76. package/skills/lit-pptx/scripts/embed_fonts.py +2 -2
  77. package/skills/lit-pptx/scripts/layout_inventory.py +35 -2
  78. package/skills/lit-pptx/scripts/lib/directive-parser.js +2 -2
  79. package/skills/lit-pptx/scripts/lib/font-map.js +4 -4
  80. package/skills/lit-pptx/scripts/lib/grid-resolver.js +521 -0
  81. package/skills/lit-pptx/scripts/lib/layout-resolver.js +403 -21
  82. package/skills/lit-pptx/scripts/lib/markdown-loader.js +6 -4
  83. package/skills/lit-pptx/scripts/lib/render-adapter-html.js +3 -9
  84. package/skills/lit-pptx/scripts/lib/render-adapter-pptx.js +500 -113
  85. package/skills/lit-pptx/scripts/lib/render-pack.js +3558 -0
  86. package/skills/lit-pptx/scripts/lib/slide-ast.js +55 -24
  87. package/skills/lit-pptx/scripts/lib/spec-validator.js +2 -2
  88. package/skills/lit-pptx/scripts/lib/template-registry.js +335 -11
  89. package/skills/lit-pptx/scripts/qa_deck.py +48 -13
  90. package/skills/lit-pptx/scripts/validate_pptx.py +25 -7
  91. package/skills/lit-pptx/specs/template-enrollment-contract.md +4 -4
  92. package/skills/lit-pptx/templates/boilerplate_professional.css +13 -13
  93. package/skills/lit-pptx/templates/enrolled/AZURE-A2Z/layout-mapping.yaml +1 -1
  94. package/skills/lit-pptx/templates/enrolled/AZURE-A2Z/template.yaml +5 -2
  95. package/skills/lit-pptx/templates/enrolled/AZURE-PRO/capabilities.yaml +20 -20
  96. package/skills/lit-pptx/templates/enrolled/AZURE-PRO/layout-mapping.yaml +1 -11
  97. package/skills/lit-pptx/templates/enrolled/AZURE-PRO/template.yaml +10 -7
  98. package/skills/lit-pptx/templates/enrolled/BOILERPLATE-PRETENDARD/capabilities.yaml +17 -17
  99. package/skills/lit-pptx/templates/enrolled/BOILERPLATE-PRETENDARD/template.yaml +8 -8
  100. package/skills/lit-pptx/templates/tonalities/atlas/pack.yaml +99 -0
  101. package/skills/lit-pptx/templates/tonalities/chalk/pack.yaml +104 -0
  102. package/skills/lit-pptx/templates/tonalities/gazette/pack.yaml +100 -0
  103. package/skills/lit-pptx/templates/tonalities/ledger/pack.yaml +101 -0
  104. package/skills/lit-pptx/templates/tonalities/night/pack.yaml +100 -0
  105. package/skills/lit-pptx/templates/tonalities/paper/pack.yaml +105 -0
  106. package/skills/lit-pptx/templates/tonalities/signal/pack.yaml +101 -0
  107. package/skills/lit-pptx/templates/tonalities/studio/pack.yaml +99 -0
  108. package/skills/lit-typographic-motion/THIRD_PARTY_NOTICES +1 -1
  109. package/skills/lit-typographic-motion/engine/stage/capture.mjs +2 -2
  110. package/skills/lit-typographic-motion/engine/treatment.mjs +1 -1
  111. package/skills/lit-typographic-motion/fonts/pins.json +2 -2
  112. package/skills/lit-typographic-motion/gate.mjs +1 -1
  113. package/skills/lit-typographic-motion/references/stage.md +1 -1
  114. package/skills/lit-typographic-motion/references/style-bibles.md +2 -2
  115. package/skills/lit-typographic-motion/references/treatment.md +1 -1
  116. package/skills/lit-typographic-motion/references/type-craft.md +3 -3
  117. package/skills/managed-skill-manifest.json +288 -44
  118. package/skills/lit-pptx/pretendard-font/public/static/LitOpenCodeSans-Bold.otf +0 -0
  119. package/skills/lit-pptx/pretendard-font/public/static/LitOpenCodeSans-Regular.otf +0 -0
@@ -17,10 +17,16 @@ 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
23
28
  import argparse
29
+ import copy
24
30
  import re
25
31
  import sys
26
32
  from pathlib import Path
@@ -41,6 +47,7 @@ try:
41
47
  from docx.enum.style import WD_STYLE_TYPE
42
48
  from docx.oxml.ns import qn
43
49
  from docx.oxml import OxmlElement
50
+ from docx.text.paragraph import Paragraph
44
51
  except ImportError:
45
52
  print("Error: 'python-docx' library not found. Install with: pip install python-docx")
46
53
  sys.exit(1)
@@ -51,6 +58,8 @@ except ImportError:
51
58
  print("Error: 'beautifulsoup4' library not found. Install with: pip install beautifulsoup4")
52
59
  sys.exit(1)
53
60
 
61
+ import docx_design # noqa: E402 (needs the runtime's python-docx and PyYAML)
62
+
54
63
 
55
64
  # ===========================================================================
56
65
  # Legacy HTML→DOCX helpers (unchanged from pre-M1 — preserved byte-for-byte)
@@ -153,10 +162,16 @@ def process_list(doc: Document, element, md_path: Path, ordered: bool = False, l
153
162
  if level > 0:
154
163
  para.paragraph_format.left_indent = Inches(0.5 * level)
155
164
 
156
- # Process direct text content
157
- for child in li.children:
165
+ # Process direct text content. Only the item's own edges are trimmed: the space after a bold lead
166
+ # ("**1,200개** 태그") belongs to the text.
167
+ children = list(li.children)
168
+ for ci, child in enumerate(children):
158
169
  if isinstance(child, NavigableString):
159
- text = str(child).strip()
170
+ text = str(child).replace("\n", " ")
171
+ if ci == 0 or getattr(children[ci - 1], "name", None) in ('ul', 'ol', 'p'):
172
+ text = text.lstrip()
173
+ if ci == len(children) - 1 or getattr(children[ci + 1], "name", None) in ('ul', 'ol', 'p'):
174
+ text = text.rstrip()
160
175
  if text:
161
176
  para.add_run(text)
162
177
  elif child.name in ('ul', 'ol'):
@@ -185,52 +200,93 @@ def process_table(doc: Document, element, md_path: Path):
185
200
 
186
201
  table = doc.add_table(rows=len(rows), cols=cols)
187
202
  table.style = 'Table Grid'
188
- table.autofit = False
189
- section = doc.sections[-1]
190
- usable_width = section.page_width - section.left_margin - section.right_margin
191
- # Weight each column by its longest visible cell, with a floor so short
192
- # numeric columns remain readable and a cap so prose cannot crush peers.
193
- weights = []
194
- for idx in range(cols):
195
- lengths = [len(cells[idx].get_text(" ", strip=True)) for tr in rows
196
- if len(cells := tr.find_all(['th', 'td'])) > idx]
197
- weights.append(max(8, min(48, max(lengths, default=8))))
198
- headers = first_row.find_all(['th', 'td'])
199
- floors = [int(Inches(min(1.5, max(0.75, sum(2 if ord(c) > 127 else 1 for c in h.get_text(strip=True)) * 0.12))))
200
- for h in headers]
201
- if sum(floors) > usable_width:
202
- floors = [int(usable_width * value / sum(floors)) for value in floors]
203
- spare = usable_width - sum(floors)
204
- total = sum(weights)
205
- widths = [floor + int(spare * weight / total) for floor, weight in zip(floors, weights)]
206
- widths[-1] += usable_width - sum(widths)
207
- for idx, width in enumerate(widths):
208
- table.columns[idx].width = width
209
-
210
203
  for row_idx, tr in enumerate(rows):
211
- row = table.rows[row_idx]
212
- tr_pr = row._tr.get_or_add_trPr()
213
- tr_pr.append(OxmlElement('w:cantSplit'))
214
- if row_idx == 0:
215
- tr_pr.append(OxmlElement('w:tblHeader'))
216
- cells = tr.find_all(['th', 'td'])
217
- for col_idx, cell in enumerate(cells):
204
+ for col_idx, cell in enumerate(tr.find_all(['th', 'td'])):
218
205
  if col_idx < cols:
219
- table_cell = row.cells[col_idx]
220
- table_cell.width = widths[col_idx]
206
+ table_cell = table.rows[row_idx].cells[col_idx]
221
207
  # Clear default paragraph
222
208
  table_cell.text = ''
223
209
  para = table_cell.paragraphs[0]
224
210
  for child in cell.children:
225
211
  process_inline_elements(para, child, md_path)
226
- if re.fullmatch(r'[-+]?\d[\d,.]*(?:%|원|억|만)?', cell.get_text(strip=True)):
227
- para.alignment = WD_ALIGN_PARAGRAPH.RIGHT
228
212
 
229
213
  # Bold for header cells
230
214
  if cell.name == 'th':
231
215
  for run in para.runs:
232
216
  run.bold = True
233
217
 
218
+ # Every path, publisher profiles included, sizes columns by content, sets cells single-spaced and
219
+ # right-aligns number columns with their header.
220
+ shape_table(doc, table, [[c.get_text(" ", strip=True) for c in tr.find_all(['th', 'td'])] for tr in rows])
221
+
222
+
223
+ # A cell reads as a number when, after signs and units, only digits remain.
224
+ NUMERIC_CELL = re.compile(r"^[+\-−±▲▼△▽]?\s*[\d.,]+\s*(?:%p?|배|x|pt|[가-힣]{1,3}|[A-Za-z]{1,3})?$")
225
+
226
+
227
+ def shape_table(doc, table, texts: list[list[str]]) -> None:
228
+ """Give a table readable columns and keep it whole on the page.
229
+
230
+ Column widths follow the longest cell of each column (Hangul counted wider)
231
+ with a floor, so a short column never squeezes a long one into one word per
232
+ line. The header row repeats on a new page, a row never splits across pages,
233
+ and columns of numbers are right-aligned.
234
+ """
235
+ if not texts or not texts[0]:
236
+ return
237
+ cols = len(table.columns)
238
+ section = doc.sections[-1]
239
+ usable = section.page_width - section.left_margin - section.right_margin
240
+
241
+ def width_of(text: str) -> float:
242
+ # Hangul runs a little over twice as wide as Latin; +3 covers the cell padding.
243
+ return sum(2.3 if "\uac00" <= ch <= "\ud7a3" else 1.0 for ch in text) + 3.0
244
+
245
+ want = []
246
+ for ci in range(cols):
247
+ column = [row[ci] for row in texts if ci < len(row)]
248
+ want.append(max([width_of(t) for t in column] + [4.0]))
249
+ # Each column first gets its natural width (about 0.085 in per Latin character at
250
+ # body size, capped at 45% of the line); columns that hit the cap share what is left.
251
+ unit = Inches(0.085)
252
+ cap = usable * 0.45
253
+ natural = [min(w * unit, cap) for w in want]
254
+ capped = [ci for ci in range(cols) if want[ci] * unit > cap]
255
+ spare = usable - sum(natural)
256
+ if spare > 0 and capped:
257
+ for ci in capped:
258
+ natural[ci] += spare / len(capped)
259
+ elif spare > 0:
260
+ natural = [w + spare / cols for w in natural]
261
+ scale = usable / sum(natural)
262
+ widths = [int(w * scale) for w in natural]
263
+
264
+ table.autofit = False
265
+ for ci, column in enumerate(table.columns):
266
+ column.width = widths[ci]
267
+ for ri, row in enumerate(table.rows):
268
+ tr_pr = row._tr.get_or_add_trPr()
269
+ # Schema order puts cantSplit before trHeight and tblHeader after it.
270
+ tr_pr.insert(0, OxmlElement("w:cantSplit"))
271
+ if ri == 0:
272
+ header = OxmlElement("w:tblHeader")
273
+ tr_pr.append(header)
274
+ for ci, cell in enumerate(row.cells):
275
+ if ci < cols:
276
+ cell.width = widths[ci]
277
+ for paragraph in cell.paragraphs:
278
+ # Body line spacing (1.6 in some profiles) makes every row a double row.
279
+ paragraph.paragraph_format.line_spacing = 1.15
280
+ paragraph.paragraph_format.space_after = Pt(0)
281
+ paragraph.paragraph_format.first_line_indent = Cm(0)
282
+
283
+ for ci in range(cols):
284
+ data = [row[ci] for row in texts[1:] if ci < len(row) and row[ci].strip()]
285
+ if data and all(NUMERIC_CELL.match(t.strip()) for t in data):
286
+ for row in table.rows:
287
+ for paragraph in row.cells[ci].paragraphs:
288
+ paragraph.alignment = WD_ALIGN_PARAGRAPH.RIGHT
289
+
234
290
 
235
291
  def process_code_block(doc: Document, element):
236
292
  """Process code block with monospace formatting."""
@@ -248,6 +304,26 @@ def process_code_block(doc: Document, element):
248
304
  para._p.get_or_add_pPr().append(shading)
249
305
 
250
306
 
307
+ def add_figure_caption(doc, element, para) -> None:
308
+ """An image alone in its paragraph keeps its alt text as the caption under it
309
+ ("Figure 1." / "그림 1." bold), and the image keeps with the caption."""
310
+ images = element.find_all('img')
311
+ alt = images[0].get('alt', '').strip() if len(images) == 1 else ''
312
+ if not alt or element.get_text(strip=True):
313
+ return
314
+ para.paragraph_format.keep_with_next = True
315
+ try:
316
+ caption = doc.add_paragraph(style='Caption')
317
+ except KeyError:
318
+ caption = doc.add_paragraph()
319
+ label = docx_design.FIGURE_LABEL.match(alt)
320
+ if label:
321
+ caption.add_run(label.group(1)).bold = True
322
+ caption.add_run(alt[len(label.group(1)):])
323
+ else:
324
+ caption.add_run(alt)
325
+
326
+
251
327
  def html_to_docx(html: str, doc: Document, md_path: Path):
252
328
  """Convert HTML to DOCX elements."""
253
329
  soup = BeautifulSoup(html, 'html.parser')
@@ -266,7 +342,8 @@ def html_to_docx(html: str, doc: Document, md_path: Path):
266
342
  process_inline_elements(heading, child, md_path)
267
343
 
268
344
  elif element.name == 'p':
269
- add_paragraph_with_formatting(doc, element, md_path)
345
+ para = add_paragraph_with_formatting(doc, element, md_path)
346
+ add_figure_caption(doc, element, para)
270
347
 
271
348
  elif element.name == 'ul':
272
349
  process_list(doc, element, md_path, ordered=False)
@@ -639,6 +716,112 @@ def apply_table_style(doc, design: dict) -> None:
639
716
  existing_bottom.set(qn("w:color"), "000000")
640
717
 
641
718
 
719
+ def keep_tables_with_captions(doc) -> None:
720
+ """A table caption keeps with its table, and a table that fits one page keeps its rows together
721
+ (every row but the last keeps with the next). Pagination only: text, styles and rules are unchanged."""
722
+ section = doc.sections[-1]
723
+ frame_h = (section.page_height - section.top_margin - section.bottom_margin) / 12700
724
+ for table in doc.tables:
725
+ prev = table._tbl.getprevious()
726
+ 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()):
727
+ Paragraph(prev, table._parent).paragraph_format.keep_with_next = True
728
+ widths = [c.width / 12700 if c.width else 100 for c in table.rows[0].cells]
729
+ 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)
730
+ if est <= 0.9 * frame_h:
731
+ for row in table.rows[:-1]:
732
+ for cell in row.cells:
733
+ cell.paragraphs[0].paragraph_format.keep_with_next = True
734
+
735
+
736
+ def publisher_spacing(doc) -> None:
737
+ """Text never touches a table or a caption: the paragraph after a table stands three quarters of a body line
738
+ clear, a table caption after text 8 pt, and a figure caption keeps 8 pt under it. A paragraph after a list stands
739
+ 6 pt clear of its last item."""
740
+ body = doc.element.body
741
+ normal = doc.styles["Normal"]
742
+ size = normal.font.size.pt if normal.font.size else 11
743
+ multiple = normal.paragraph_format.line_spacing if isinstance(normal.paragraph_format.line_spacing, float) else 1.15
744
+ clear = Pt(max(8, round(size * multiple * 1.2 * 0.75)))
745
+ for el in body.iterchildren(qn("w:tbl")):
746
+ nxt = el.getnext()
747
+ 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():
748
+ para = Paragraph(nxt, doc._body)
749
+ # A heading keeps its own (larger) space above.
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
+
642
825
  def strip_banned(doc, design: dict) -> None:
643
826
  """Remove any banned font/style from runs and paragraphs."""
644
827
  banned = design.get("banned") or {}
@@ -683,6 +866,19 @@ def _set_run_fonts(run, latin: str, cjk: str | None) -> None:
683
866
  rfonts.set(qn("w:eastAsia"), cjk)
684
867
 
685
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
+
686
882
  def apply_cjk_font_pairing(doc, font_cfg: dict) -> None:
687
883
  body = font_cfg.get("body") or {}
688
884
  body_cjk = font_cfg.get("body_cjk") or {}
@@ -885,6 +1081,68 @@ def detect_locale(text: str) -> str:
885
1081
  # ===========================================================================
886
1082
 
887
1083
 
1084
+ NOTICE_INK, NOTICE_FILL = docx_design.NOTICE
1085
+
1086
+
1087
+ def _shade(element, fill: str) -> None:
1088
+ shd = OxmlElement("w:shd")
1089
+ shd.set(qn("w:val"), "clear")
1090
+ shd.set(qn("w:color"), "auto")
1091
+ shd.set(qn("w:fill"), fill)
1092
+ element.append(shd)
1093
+
1094
+
1095
+ def add_notice(doc, text: str) -> None:
1096
+ """The frontmatter `notice:` (e.g. "예시 데이터 — 수치는 가정입니다") as a tag in
1097
+ every page header, so an example document cannot be mistaken for a sourced one
1098
+ on any page it is printed from. A profile whose title page has its own, empty
1099
+ header (korean-generic) gets a tinted band under the title block instead."""
1100
+ if doc.sections[0].different_first_page_header_footer:
1101
+ band = doc.add_paragraph()
1102
+ band_run = band.add_run(str(text))
1103
+ band_run.bold = True
1104
+ band_run.font.size = Pt(10.5)
1105
+ band_run.font.color.rgb = RGBColor.from_string(NOTICE_INK)
1106
+ band.paragraph_format.space_before = Pt(4)
1107
+ band.paragraph_format.space_after = Pt(14)
1108
+ _shade(band._p.get_or_add_pPr(), NOTICE_FILL)
1109
+ for index, section in enumerate(doc.sections):
1110
+ if index and section.header.is_linked_to_previous:
1111
+ continue
1112
+ header = section.header
1113
+ tag = header.paragraphs[0] if header.paragraphs and not header.paragraphs[0].text.strip() else header.add_paragraph()
1114
+ tag_run = tag.add_run(f"\u00a0{text}\u00a0")
1115
+ tag_run.bold = True
1116
+ tag_run.font.size = Pt(9)
1117
+ tag_run.font.color.rgb = RGBColor.from_string(NOTICE_INK)
1118
+ _shade(tag_run._r.get_or_add_rPr(), NOTICE_FILL)
1119
+
1120
+
1121
+ MD_EXTENSIONS = ['tables', 'fenced_code', 'nl2br', 'sane_lists']
1122
+
1123
+
1124
+ def _md_renderer(md_path: Path):
1125
+ """Markdown into a document or a component's cell, through the legacy HTML helpers."""
1126
+ def render(container, text: str) -> None:
1127
+ html_to_docx(markdown.Markdown(extensions=MD_EXTENSIONS).convert(text), container, md_path)
1128
+ return render
1129
+
1130
+
1131
+ def _typography(design: dict, lang: str = "en"):
1132
+ """The registry's micro-typography filters, for Markdown outside the directive fences; Korean text also
1133
+ writes ISO dates the Korean way (2026. 10. 1.)."""
1134
+ def apply(text: str) -> str:
1135
+ # Dates first: the dash filter would read 2026-06-30 as a number range.
1136
+ if lang != "en":
1137
+ text = docx_design.korean_dates(text)
1138
+ text = fix_dashes(text, design.get("micro_typography", {}).get("dashes"))
1139
+ text = fix_quotes(text)
1140
+ text = fix_unit_spacing(text)
1141
+ text = fix_ellipsis(text)
1142
+ return normalize_double_spaces(text)
1143
+ return apply
1144
+
1145
+
888
1146
  def convert_md_to_docx(
889
1147
  md_path: Path,
890
1148
  docx_path: Path,
@@ -892,33 +1150,43 @@ def convert_md_to_docx(
892
1150
  publisher_name: str | None = None,
893
1151
  registry_path: Path | None = None,
894
1152
  locale: str = "auto",
1153
+ tonality: str | None = None,
1154
+ density: int | None = None,
1155
+ variance: int | None = None,
895
1156
  ):
896
1157
  """Convert Markdown file to DOCX.
897
1158
 
898
- When publisher_name is None, behavior is byte-compatible with the pre-M1
899
- script: read md, convert to HTML, html_to_docx, save. Regression-guarded.
1159
+ With a tonality (argument or frontmatter `tonality:`), the document is built
1160
+ by docx_design in that design direction. Without a tonality or a publisher,
1161
+ the plain profile runs through the same builders with neutral tokens (A4,
1162
+ Pretendard, booktabs tables, notice in every header).
900
1163
 
901
1164
  When publisher_name is set, apply the full journal pipeline: registry
902
1165
  load, frontmatter parse, pre-MD filters, title-block injection, post-HTML
903
1166
  heading numbering, booktabs tables, banned stripping, CJK font pairing.
1167
+ A publisher run writes the same document.xml as before the tonality
1168
+ engine unless the source holds figures (captions) or directives.
904
1169
  """
905
1170
  if publisher_name is None:
906
- # --- Legacy code path (regression-critical: do not mutate) ---
907
- md_text = md_path.read_text(encoding='utf-8')
908
- md_converter = markdown.Markdown(extensions=[
909
- 'tables',
910
- 'fenced_code',
911
- 'nl2br',
912
- 'sane_lists',
913
- ])
914
- html = md_converter.convert(md_text)
915
- if template_path and template_path.exists():
916
- doc = Document(str(template_path))
917
- else:
918
- doc = Document()
919
- html_to_docx(html, doc, md_path)
1171
+ md_text = md_path.read_text(encoding="utf-8")
1172
+ front, body = parse_frontmatter(md_text) if md_text.startswith("---") else ({}, md_text)
1173
+ name = tonality or front.get("tonality")
1174
+ lang = "en" if (detect_locale(body) if locale == "auto" else locale) == "en" else "ko"
1175
+ dials = {k: v for k, v in (("density", density if density is not None else front.get("density")),
1176
+ ("variance", variance if variance is not None else front.get("variance"))) if v is not None}
1177
+ try:
1178
+ if name:
1179
+ T = docx_design.load_tonality(name, locale=lang, **{k: int(v) for k, v in dials.items()})
1180
+ filters = _typography(T.design, lang)
1181
+ else:
1182
+ T, filters = docx_design.neutral_tonality(lang), None
1183
+ doc = Document(str(template_path)) if template_path and template_path.exists() else Document()
1184
+ docx_design.build(doc, front, body, T, _md_renderer(md_path), lang, filters)
1185
+ except docx_design.DesignError as exc:
1186
+ raise SystemExit(f"Error: {exc}") from exc
920
1187
  doc.save(str(docx_path))
921
- print(f"Successfully converted {md_path} to {docx_path}")
1188
+ label = f"tonality={T.name}, density={T.density}, variance={T.variance}" if not T.neutral else "plain profile"
1189
+ print(f"Successfully converted {md_path} to {docx_path} [{label}, locale={lang}]")
922
1190
  return
923
1191
 
924
1192
  # --- Journal workflow ---
@@ -930,17 +1198,29 @@ def convert_md_to_docx(
930
1198
 
931
1199
  md_text = md_path.read_text(encoding="utf-8")
932
1200
  frontmatter, body = parse_frontmatter(md_text)
1201
+ if frontmatter.get("tonality"):
1202
+ raise SystemExit(f"Error: frontmatter tonality: {frontmatter['tonality']} and --publisher {publisher_name} are mutually exclusive")
933
1203
 
934
1204
  # Locale detection (informational in M1; Korean rules land in M6)
935
1205
  detected_locale = detect_locale(body) if locale == "auto" else locale
936
1206
 
937
1207
  # Pre-MD micro-typography filters
1208
+ if detected_locale in ("ko", "mixed"):
1209
+ # No ISO dates in Korean text on any path. Before the dash filter, which would read 2026-06-30 as a
1210
+ # number range.
1211
+ body = docx_design.korean_dates(body)
1212
+ if frontmatter.get("date"):
1213
+ frontmatter["date"] = docx_design.format_date(frontmatter["date"], "ko")
938
1214
  dash_rules = design.get("micro_typography", {}).get("dashes")
939
- body = fix_dashes(body, dash_rules)
940
- body = fix_quotes(body)
941
- body = fix_unit_spacing(body)
942
- body = fix_ellipsis(body)
943
- body = normalize_double_spaces(body)
1215
+
1216
+ def typeset(text: str) -> str:
1217
+ return normalize_double_spaces(fix_ellipsis(fix_unit_spacing(fix_quotes(fix_dashes(text, dash_rules)))))
1218
+
1219
+ # Directive attributes on a ::: line (title="...") must keep straight quotes or the directive stops parsing.
1220
+ # Only the text between those lines is typeset, one stretch at a time, so quote pairs and code fences keep
1221
+ # their context and a source with no directive is typeset in one piece.
1222
+ segments = re.split(r"^([ \t]*:{3,}.*)$", body, flags=re.M)
1223
+ body = "".join(segment if n % 2 else typeset(segment) for n, segment in enumerate(segments))
944
1224
 
945
1225
  # Load template if available
946
1226
  template_docx = script_dir.parent / "templates" / "docx" / f"{publisher_name}.docx"
@@ -965,22 +1245,46 @@ def convert_md_to_docx(
965
1245
  # Title block (built from frontmatter) before main content
966
1246
  if frontmatter.get("title"):
967
1247
  inject_title_block(doc, frontmatter, design)
1248
+ if frontmatter.get("notice"):
1249
+ add_notice(doc, frontmatter["notice"])
1250
+ page_numbers(doc, design)
968
1251
 
969
1252
  # Main content
970
- md_converter = markdown.Markdown(extensions=[
971
- 'tables',
972
- 'fenced_code',
973
- 'nl2br',
974
- 'sane_lists',
975
- ])
976
- html = md_converter.convert(body)
977
- html_to_docx(html, doc, md_path)
1253
+ if docx_design.has_directives(body):
1254
+ # Components in a journal manuscript are drawn with neutral styling (ink and line only).
1255
+ neutral = docx_design.neutral_tonality("en" if detected_locale == "en" else "ko")
1256
+ docx_design.setup_component_styles(doc, neutral)
1257
+ ctx = docx_design.Build(doc, neutral, _md_renderer(md_path), "en" if detected_locale == "en" else "ko")
1258
+ try:
1259
+ ctx.render(doc, body)
1260
+ except docx_design.DesignError as exc:
1261
+ raise SystemExit(f"Error: {exc}") from exc
1262
+ print("Note: the ::: components are drawn with publisher-neutral styling under a publisher profile")
1263
+ else:
1264
+ md_converter = markdown.Markdown(extensions=[
1265
+ 'tables',
1266
+ 'fenced_code',
1267
+ 'nl2br',
1268
+ 'sane_lists',
1269
+ ])
1270
+ html = md_converter.convert(body)
1271
+ html_to_docx(html, doc, md_path)
978
1272
 
979
1273
  # Post-HTML design enforcement
980
1274
  apply_heading_numbering(doc, design)
981
1275
  apply_table_style(doc, design)
1276
+ keep_tables_with_captions(doc)
1277
+ plain_table_cells(doc)
1278
+ publisher_spacing(doc)
1279
+ # No column narrower than its longest word ("Cataly/st" wrapped inside a publisher table).
1280
+ normal = doc.styles["Normal"].font.size
1281
+ for table in doc.tables:
1282
+ docx_design.fit_word_widths(table, normal.pt if normal else 11)
1283
+ docx_design.restart_numbered_lists(doc)
1284
+ docx_design.keep_short_lists(doc)
982
1285
  strip_banned(doc, design)
983
1286
  apply_cjk_font_pairing(doc, docx_font)
1287
+ drop_shadowed_theme_fonts(doc)
984
1288
 
985
1289
  doc.save(str(docx_path))
986
1290
  print(
@@ -1010,6 +1314,14 @@ def parse_args() -> argparse.Namespace:
1010
1314
  help="Path to templates/registry.yaml (defaults to the one in this skill)",
1011
1315
  default=None,
1012
1316
  )
1317
+ parser.add_argument(
1318
+ "--tonality",
1319
+ help="Design direction from templates/tonalities/: Report, Brief, Manual, Proposal, Memo or Journal "
1320
+ "(frontmatter tonality: works too)",
1321
+ default=None,
1322
+ )
1323
+ parser.add_argument("--density", type=int, default=None, help="Density dial 1-10 (default: the pack's)")
1324
+ parser.add_argument("--variance", type=int, default=None, help="Variance dial 1-10 (default: the pack's)")
1013
1325
  parser.add_argument(
1014
1326
  "--locale",
1015
1327
  choices=("auto", "en", "ko", "mixed"),
@@ -1019,6 +1331,8 @@ def parse_args() -> argparse.Namespace:
1019
1331
  args = parser.parse_args()
1020
1332
  if args.publisher and args.template:
1021
1333
  parser.error("--publisher and --template are mutually exclusive")
1334
+ if args.publisher and args.tonality:
1335
+ parser.error("--tonality and --publisher are mutually exclusive: a tonality is a design direction, a publisher a journal profile")
1022
1336
  return args
1023
1337
 
1024
1338
 
@@ -1039,6 +1353,9 @@ def main() -> int:
1039
1353
  publisher_name=args.publisher,
1040
1354
  registry_path=registry_path,
1041
1355
  locale=args.locale,
1356
+ tonality=args.tonality,
1357
+ density=args.density,
1358
+ variance=args.variance,
1042
1359
  )
1043
1360
  return 0
1044
1361