@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.
- package/.grok/hooks/auto-handoff-gate.mjs +15 -2
- package/.grok/rules/00-litgrok.md +1 -1
- package/.grok/skills/frontend-ui-ux/THIRD-PARTY-NOTICE.txt +0 -5
- package/.grok/skills/frontend-ui-ux/references/slop-register.md +4 -4
- package/.grok/skills/frontend-ui-ux/scripts/import-design-intelligence.mjs +1 -1
- package/.grok/skills/lit-docx/SKILL.md +59 -11
- package/.grok/skills/lit-docx/examples/01-report-ko.md +137 -0
- package/.grok/skills/lit-docx/examples/02-brief-ko.md +82 -0
- package/.grok/skills/lit-docx/examples/03-manual-ko.md +118 -0
- package/.grok/skills/lit-docx/examples/04-proposal-ko.md +92 -0
- package/.grok/skills/lit-docx/examples/05-memo-en.md +42 -0
- package/.grok/skills/lit-docx/examples/06-journal-en.md +90 -0
- package/.grok/skills/lit-docx/examples/07-report-en.md +103 -0
- package/.grok/skills/lit-docx/examples/08-proposal-en.md +90 -0
- package/.grok/skills/lit-docx/examples/09-memo-ko.md +41 -0
- package/.grok/skills/lit-docx/references/components.md +151 -0
- package/.grok/skills/lit-docx/references/direction-step.md +129 -0
- package/.grok/skills/lit-docx/references/page-composition.md +129 -0
- package/.grok/skills/lit-docx/references/tonalities/brief.md +98 -0
- package/.grok/skills/lit-docx/references/tonalities/journal.md +100 -0
- package/.grok/skills/lit-docx/references/tonalities/manual.md +107 -0
- package/.grok/skills/lit-docx/references/tonalities/memo.md +95 -0
- package/.grok/skills/lit-docx/references/tonalities/proposal.md +105 -0
- package/.grok/skills/lit-docx/references/tonalities/report.md +114 -0
- package/.grok/skills/lit-docx/scripts/convert_md_to_docx.py +394 -51
- package/.grok/skills/lit-docx/scripts/docx_design.py +2446 -0
- package/.grok/skills/lit-docx/scripts/docx_gate.py +203 -0
- package/.grok/skills/lit-docx/scripts/docx_layout.py +959 -0
- package/.grok/skills/lit-docx/scripts/run.mjs +3 -2
- package/.grok/skills/lit-docx/scripts/slop_lint.py +63 -17
- package/.grok/skills/lit-docx/templates/tonalities/brief.yaml +29 -0
- package/.grok/skills/lit-docx/templates/tonalities/journal.yaml +26 -0
- package/.grok/skills/lit-docx/templates/tonalities/manual.yaml +27 -0
- package/.grok/skills/lit-docx/templates/tonalities/memo.yaml +27 -0
- package/.grok/skills/lit-docx/templates/tonalities/proposal.yaml +27 -0
- package/.grok/skills/lit-docx/templates/tonalities/report.yaml +27 -0
- package/.grok/skills/lit-pptx/NOTICE.md +1 -1
- package/.grok/skills/lit-pptx/SKILL.md +70 -14
- package/.grok/skills/lit-pptx/agents/analyst.md +1 -1
- package/.grok/skills/lit-pptx/agents/thesis.md +2 -2
- package/.grok/skills/lit-pptx/examples/01-pitch-signal-ko.md +175 -0
- package/.grok/skills/lit-pptx/examples/02-business-review-ledger-ko.md +199 -0
- package/.grok/skills/lit-pptx/examples/03-research-talk-paper-en.md +177 -0
- package/.grok/skills/lit-pptx/examples/04-lecture-chalk-ko.md +176 -0
- package/.grok/skills/lit-pptx/examples/05-data-review-night-en.md +221 -0
- package/.grok/skills/lit-pptx/examples/06-image-led-atlas-en.md +143 -0
- package/.grok/skills/lit-pptx/examples/07-briefing-gazette-ko.md +192 -0
- package/.grok/skills/lit-pptx/examples/08-status-update-ledger-en.md +168 -0
- package/.grok/skills/lit-pptx/examples/09-pitch-studio-en.md +157 -0
- package/.grok/skills/lit-pptx/examples/10-image-led-studio-ko.md +175 -0
- package/.grok/skills/lit-pptx/examples/11-business-review-night-en.md +181 -0
- package/.grok/skills/lit-pptx/examples/12-status-update-gazette-ko.md +199 -0
- package/.grok/skills/lit-pptx/pretendard-font/public/static/Pretendard-Bold.otf +0 -0
- package/.grok/skills/lit-pptx/pretendard-font/public/static/Pretendard-Regular.otf +0 -0
- package/.grok/skills/lit-pptx/references/density-and-fill.md +155 -0
- package/.grok/skills/lit-pptx/references/direction-step.md +211 -0
- package/.grok/skills/lit-pptx/references/layout-families.md +193 -0
- package/.grok/skills/lit-pptx/references/title-treatments.md +134 -0
- package/.grok/skills/lit-pptx/references/tonalities/atlas.md +124 -0
- package/.grok/skills/lit-pptx/references/tonalities/chalk.md +135 -0
- package/.grok/skills/lit-pptx/references/tonalities/gazette.md +127 -0
- package/.grok/skills/lit-pptx/references/tonalities/ledger.md +134 -0
- package/.grok/skills/lit-pptx/references/tonalities/night.md +132 -0
- package/.grok/skills/lit-pptx/references/tonalities/paper.md +127 -0
- package/.grok/skills/lit-pptx/references/tonalities/signal.md +118 -0
- package/.grok/skills/lit-pptx/references/tonalities/studio.md +123 -0
- package/.grok/skills/lit-pptx/scripts/compile-deck.js +65 -17
- package/.grok/skills/lit-pptx/scripts/craft_extras.py +82 -18
- package/.grok/skills/lit-pptx/scripts/deck_output.py +598 -0
- package/.grok/skills/lit-pptx/scripts/embed_fonts.py +1 -1
- package/.grok/skills/lit-pptx/scripts/inventory.py +6 -2
- package/.grok/skills/lit-pptx/scripts/lib/directive-parser.js +2 -2
- package/.grok/skills/lit-pptx/scripts/lib/grid-resolver.js +521 -0
- package/.grok/skills/lit-pptx/scripts/lib/layout-resolver.js +401 -16
- package/.grok/skills/lit-pptx/scripts/lib/markdown-loader.js +6 -4
- package/.grok/skills/lit-pptx/scripts/lib/render-adapter-html.js +2 -2
- package/.grok/skills/lit-pptx/scripts/lib/render-adapter-pptx.js +500 -126
- package/.grok/skills/lit-pptx/scripts/lib/render-pack.js +3558 -0
- package/.grok/skills/lit-pptx/scripts/lib/slide-ast.js +54 -6
- package/.grok/skills/lit-pptx/scripts/lib/template-registry.js +335 -11
- package/.grok/skills/lit-pptx/scripts/qa_deck.py +43 -10
- package/.grok/skills/lit-pptx/scripts/run.mjs +3 -2
- package/.grok/skills/lit-pptx/scripts/validate_pptx.py +17 -5
- package/.grok/skills/lit-pptx/templates/boilerplate_professional.css +6 -6
- package/.grok/skills/lit-pptx/templates/enrolled/AZURE-A2Z/layout-mapping.yaml +1 -1
- package/.grok/skills/lit-pptx/templates/enrolled/AZURE-A2Z/template.yaml +4 -1
- package/.grok/skills/lit-pptx/templates/enrolled/AZURE-PRO/layout-mapping.yaml +1 -1
- package/.grok/skills/lit-pptx/templates/enrolled/AZURE-PRO/template.yaml +4 -1
- package/.grok/skills/lit-pptx/templates/tonalities/atlas/pack.yaml +99 -0
- package/.grok/skills/lit-pptx/templates/tonalities/chalk/pack.yaml +104 -0
- package/.grok/skills/lit-pptx/templates/tonalities/gazette/pack.yaml +99 -0
- package/.grok/skills/lit-pptx/templates/tonalities/ledger/pack.yaml +100 -0
- package/.grok/skills/lit-pptx/templates/tonalities/night/pack.yaml +100 -0
- package/.grok/skills/lit-pptx/templates/tonalities/paper/pack.yaml +105 -0
- package/.grok/skills/lit-pptx/templates/tonalities/signal/pack.yaml +101 -0
- package/.grok/skills/lit-pptx/templates/tonalities/studio/pack.yaml +99 -0
- package/.grok/skills/lit-typographic-motion/THIRD_PARTY_NOTICES +1 -1
- package/.grok/skills/lit-typographic-motion/fonts/pins.json +6 -6
- package/.grok/skills/lit-typographic-motion/references/runtime.md +1 -1
- package/.grok/skills/lit-typographic-motion/references/style-bible.md +2 -2
- package/.grok/skills/lit-typographic-motion/references/type-craft.md +2 -2
- package/.grok/skills/lit-typographic-motion/scripts/lib/gate.mjs +1 -1
- package/.grok/skills/litgrok/SKILL.md +1 -1
- package/.grok-plugin/plugin.json +1 -1
- package/CHANGELOG.md +11 -0
- package/README.md +9 -9
- package/README_ko-KR.md +9 -9
- package/docs/assets/readme/badge-version.svg +1 -1
- package/docs/reference.md +1 -1
- package/docs/reference_ko-KR.md +1 -1
- package/package.json +1 -1
- package/plugin.json +1 -1
- package/.grok/skills/lit-pptx/pretendard-font/public/static/PretendardGOV-Bold.otf +0 -0
- 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
|
-
|
|
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).
|
|
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
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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
|
-
|
|
886
|
-
|
|
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
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
doc = Document()
|
|
906
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
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
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
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
|
|