@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.
- package/CHANGELOG.md +13 -0
- package/README-Ko-KR.md +11 -11
- package/README.md +11 -11
- package/dist/activation-routing.js +1 -1
- package/dist/deliverable-hedge-guard.js +3 -1
- package/dist/features.js +2 -2
- package/dist/hooks.js +2 -1
- package/dist/skills.js +6 -4
- package/docs/assets/readme/badge-version.svg +1 -1
- package/docs/reference-Ko-KR.md +1 -1
- package/package.json +1 -1
- package/skills/frontend-ui-ux/data/THIRD-PARTY-NOTICE.txt +1 -3
- package/skills/lit-docx/SKILL.md +80 -19
- package/skills/lit-docx/references/components.md +253 -0
- package/skills/lit-docx/references/direction-step.md +234 -0
- package/skills/lit-docx/references/examples/01-report-ko.md +160 -0
- package/skills/lit-docx/references/examples/02-brief-ko.md +58 -0
- package/skills/lit-docx/references/examples/03-manual-ko.md +107 -0
- package/skills/lit-docx/references/examples/04-proposal-ko.md +93 -0
- package/skills/lit-docx/references/examples/05-memo-ko.md +42 -0
- package/skills/lit-docx/references/examples/06-journal-en.md +84 -0
- package/skills/lit-docx/references/examples/07-report-en.md +137 -0
- package/skills/lit-docx/references/examples/08-journal-ko.md +93 -0
- package/skills/lit-docx/references/examples/09-brief-en.md +64 -0
- package/skills/lit-docx/references/page-composition.md +306 -0
- package/skills/lit-docx/references/tonalities/brief.md +104 -0
- package/skills/lit-docx/references/tonalities/journal.md +101 -0
- package/skills/lit-docx/references/tonalities/manual.md +111 -0
- package/skills/lit-docx/references/tonalities/memo.md +105 -0
- package/skills/lit-docx/references/tonalities/proposal.md +101 -0
- package/skills/lit-docx/references/tonalities/report.md +126 -0
- package/skills/lit-docx/run.py +2 -1
- package/skills/lit-docx/scripts/convert_md_to_docx.py +384 -67
- package/skills/lit-docx/scripts/craft_docx.py +204 -1
- package/skills/lit-docx/scripts/docx_design.py +2445 -0
- package/skills/lit-docx/scripts/docx_layout.py +959 -0
- package/skills/lit-docx/scripts/slop_lint.py +23 -6
- package/skills/lit-docx/templates/tonalities/brief.yaml +28 -0
- package/skills/lit-docx/templates/tonalities/journal.yaml +25 -0
- package/skills/lit-docx/templates/tonalities/manual.yaml +26 -0
- package/skills/lit-docx/templates/tonalities/memo.yaml +26 -0
- package/skills/lit-docx/templates/tonalities/proposal.yaml +26 -0
- package/skills/lit-docx/templates/tonalities/report.yaml +26 -0
- package/skills/lit-pptx/NOTICE.md +1 -1
- package/skills/lit-pptx/SKILL.md +108 -19
- package/skills/lit-pptx/pretendard-font/public/static/Pretendard-Bold.otf +0 -0
- package/skills/lit-pptx/pretendard-font/public/static/Pretendard-Regular.otf +0 -0
- package/skills/lit-pptx/references/density-and-fill.md +283 -0
- package/skills/lit-pptx/references/direction-step.md +249 -0
- package/skills/lit-pptx/references/examples/01-ops-review-ledger-ko.md +173 -0
- package/skills/lit-pptx/references/examples/02-launch-pitch-signal-en.md +153 -0
- package/skills/lit-pptx/references/examples/03-field-survey-atlas-ko.md +120 -0
- package/skills/lit-pptx/references/examples/04-lecture-chalk-ko.md +132 -0
- package/skills/lit-pptx/references/examples/05-research-talk-paper-en.md +162 -0
- package/skills/lit-pptx/references/examples/06-policy-briefing-gazette-ko.md +176 -0
- package/skills/lit-pptx/references/examples/07-annual-story-studio-en.md +131 -0
- package/skills/lit-pptx/references/examples/08-reliability-keynote-night-en.md +182 -0
- package/skills/lit-pptx/references/examples/09-status-update-ledger-en.md +147 -0
- package/skills/lit-pptx/references/examples/10-investor-pitch-signal-ko.md +136 -0
- package/skills/lit-pptx/references/examples/11-forecast-review-night-ko.md +177 -0
- package/skills/lit-pptx/references/examples/12-exhibition-proposal-studio-ko.md +149 -0
- package/skills/lit-pptx/references/layout-families.md +470 -0
- package/skills/lit-pptx/references/title-treatments.md +271 -0
- package/skills/lit-pptx/references/tonalities/atlas.md +131 -0
- package/skills/lit-pptx/references/tonalities/chalk.md +132 -0
- package/skills/lit-pptx/references/tonalities/gazette.md +137 -0
- package/skills/lit-pptx/references/tonalities/ledger.md +150 -0
- package/skills/lit-pptx/references/tonalities/night.md +148 -0
- package/skills/lit-pptx/references/tonalities/paper.md +135 -0
- package/skills/lit-pptx/references/tonalities/signal.md +147 -0
- package/skills/lit-pptx/references/tonalities/studio.md +149 -0
- package/skills/lit-pptx/scripts/compile-deck.js +68 -21
- package/skills/lit-pptx/scripts/craft-thresholds.json +45 -1
- package/skills/lit-pptx/scripts/craft_extras.py +37 -8
- package/skills/lit-pptx/scripts/deck_output.py +654 -0
- package/skills/lit-pptx/scripts/embed_fonts.py +2 -2
- package/skills/lit-pptx/scripts/layout_inventory.py +35 -2
- package/skills/lit-pptx/scripts/lib/directive-parser.js +2 -2
- package/skills/lit-pptx/scripts/lib/font-map.js +4 -4
- package/skills/lit-pptx/scripts/lib/grid-resolver.js +521 -0
- package/skills/lit-pptx/scripts/lib/layout-resolver.js +403 -21
- package/skills/lit-pptx/scripts/lib/markdown-loader.js +6 -4
- package/skills/lit-pptx/scripts/lib/render-adapter-html.js +3 -9
- package/skills/lit-pptx/scripts/lib/render-adapter-pptx.js +500 -113
- package/skills/lit-pptx/scripts/lib/render-pack.js +3558 -0
- package/skills/lit-pptx/scripts/lib/slide-ast.js +55 -24
- package/skills/lit-pptx/scripts/lib/spec-validator.js +2 -2
- package/skills/lit-pptx/scripts/lib/template-registry.js +335 -11
- package/skills/lit-pptx/scripts/qa_deck.py +48 -13
- package/skills/lit-pptx/scripts/validate_pptx.py +25 -7
- package/skills/lit-pptx/specs/template-enrollment-contract.md +4 -4
- package/skills/lit-pptx/templates/boilerplate_professional.css +13 -13
- package/skills/lit-pptx/templates/enrolled/AZURE-A2Z/layout-mapping.yaml +1 -1
- package/skills/lit-pptx/templates/enrolled/AZURE-A2Z/template.yaml +5 -2
- package/skills/lit-pptx/templates/enrolled/AZURE-PRO/capabilities.yaml +20 -20
- package/skills/lit-pptx/templates/enrolled/AZURE-PRO/layout-mapping.yaml +1 -11
- package/skills/lit-pptx/templates/enrolled/AZURE-PRO/template.yaml +10 -7
- package/skills/lit-pptx/templates/enrolled/BOILERPLATE-PRETENDARD/capabilities.yaml +17 -17
- package/skills/lit-pptx/templates/enrolled/BOILERPLATE-PRETENDARD/template.yaml +8 -8
- package/skills/lit-pptx/templates/tonalities/atlas/pack.yaml +99 -0
- package/skills/lit-pptx/templates/tonalities/chalk/pack.yaml +104 -0
- package/skills/lit-pptx/templates/tonalities/gazette/pack.yaml +100 -0
- package/skills/lit-pptx/templates/tonalities/ledger/pack.yaml +101 -0
- package/skills/lit-pptx/templates/tonalities/night/pack.yaml +100 -0
- package/skills/lit-pptx/templates/tonalities/paper/pack.yaml +105 -0
- package/skills/lit-pptx/templates/tonalities/signal/pack.yaml +101 -0
- package/skills/lit-pptx/templates/tonalities/studio/pack.yaml +99 -0
- package/skills/lit-typographic-motion/THIRD_PARTY_NOTICES +1 -1
- package/skills/lit-typographic-motion/engine/stage/capture.mjs +2 -2
- package/skills/lit-typographic-motion/engine/treatment.mjs +1 -1
- package/skills/lit-typographic-motion/fonts/pins.json +2 -2
- package/skills/lit-typographic-motion/gate.mjs +1 -1
- package/skills/lit-typographic-motion/references/stage.md +1 -1
- package/skills/lit-typographic-motion/references/style-bibles.md +2 -2
- package/skills/lit-typographic-motion/references/treatment.md +1 -1
- package/skills/lit-typographic-motion/references/type-craft.md +3 -3
- package/skills/managed-skill-manifest.json +288 -44
- package/skills/lit-pptx/pretendard-font/public/static/LitOpenCodeSans-Bold.otf +0 -0
- 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
|
-
|
|
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).
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
899
|
-
|
|
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
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
doc = Document()
|
|
919
|
-
|
|
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
|
-
|
|
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
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
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
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
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
|
|