Skip to content

Commit bd0441c

Browse files
Fix DOCX table header bold and add proportional column widths (#24)
1 parent c1adff5 commit bd0441c

File tree

5 files changed

+116
-18
lines changed

5 files changed

+116
-18
lines changed

RELEASES.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@
55
### New features
66
- **Document sharing** — Share documents via encrypted URL links. Content is compressed (pako deflate) and encrypted (AES-256-GCM) client-side, then embedded in the URL fragment which never leaves the browser. Two modes: link-only (encryption key in URL) and password-protected (PBKDF2 key derivation). Configurable link expiry (1h to 30 days). Import shared documents with Replace All or Merge options. Optional URL shortening via is.gd.
77

8+
### Fixes
9+
- **DOCX table formatting** — Header row is now properly bold in exported DOCX. Table spans full document width with column widths proportional to content length.
10+
811
### Changed
912
- **Honest title page preview** — Live preview now shows title page only from explicit YAML frontmatter instead of auto-generating phantom titles and dates from H1 headings and `new Date()`. When no frontmatter is present, a clickable placeholder guides users to add metadata. DOCX/PDF/HTML export retains auto-fallback behavior for professional output.
1013
- **Document Options** — "Export Options" renamed to "Document Options". Toggles (Title Page, ToC, Header, Footer) now affect both live preview and export, making the editor truly WYSIWYG.

src/docx-renderer.js

Lines changed: 37 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { loadLogoPng } from "./logo.js";
66
import { parseInlineSegments } from "./inline-formatting.js";
77

88
const getDocx = () => window.docx;
9+
const A4_WIDTH_TWIPS = 11906;
910

1011
function dataUrlToBase64(dataUrl) {
1112
return dataUrl ? dataUrl.split(",")[1] : null;
@@ -51,7 +52,7 @@ function themeAdapter(theme) {
5152
};
5253
}
5354

54-
export function parseInlineFormatting(text, t) {
55+
export function parseInlineFormatting(text, t, overrides = {}) {
5556
const { TextRun, ShadingType } = getDocx();
5657
const boldColor = t.colorBold || COLORS.boldFallback;
5758
const segments = parseInlineSegments(text);
@@ -76,6 +77,7 @@ export function parseInlineFormatting(text, t) {
7677
font: t.fontBody,
7778
size: t.sizeBody,
7879
...styleMap[seg.type],
80+
...overrides,
7981
})
8082
);
8183
}
@@ -525,28 +527,45 @@ async function elementToDocx(element, theme, t) {
525527
const border = { style: BorderStyle.SINGLE, size: 1, color: t.colorTableBorder };
526528
const borders = { top: border, bottom: border, left: border, right: border };
527529
const colCount = element.rows[0]?.length || 1;
528-
const colWidth = Math.floor(9360 / colCount);
530+
const tableWidth = A4_WIDTH_TWIPS - 2 * t.marginPage;
531+
532+
const charWidth = t.sizeTable * 5;
533+
const cellPadding = 240;
534+
const minColWidth = 3 * charWidth + cellPadding;
535+
536+
const maxLengths = Array(colCount).fill(0);
537+
for (const row of element.rows) {
538+
for (let c = 0; c < colCount; c++) {
539+
if (row[c]) maxLengths[c] = Math.max(maxLengths[c], row[c].length);
540+
}
541+
}
542+
543+
const sqrtLengths = maxLengths.map((len) => Math.sqrt(Math.max(len, 1)));
544+
const totalSqrt = sqrtLengths.reduce((sum, l) => sum + l, 0);
545+
const rawWidths = sqrtLengths.map((l) =>
546+
Math.max(Math.floor((l / totalSqrt) * tableWidth), minColWidth)
547+
);
548+
const rawTotal = rawWidths.reduce((sum, w) => sum + w, 0);
549+
const colWidths = rawWidths.map((w) => Math.floor((w / rawTotal) * tableWidth));
550+
551+
const cellTheme = { ...t, sizeBody: t.sizeTable, sizeMono: t.sizeTable - 2 };
552+
529553
return new Table({
530-
width: { size: 100, type: WidthType.PERCENTAGE },
531-
columnWidths: Array(colCount).fill(colWidth),
554+
width: { size: tableWidth, type: WidthType.DXA },
555+
columnWidths: colWidths,
532556
rows: element.rows.map(
533557
(row, rowIdx) =>
534558
new TableRow({
535559
children: row.map((cell, colIdx) => {
536560
const isHeader = rowIdx === 0;
537-
const isFirstCol = colIdx === 0;
538-
const cellRuns = parseInlineFormatting(cell, {
539-
...t,
540-
sizeBody: t.sizeTable,
541-
sizeMono: t.sizeTable - 2,
542-
});
543-
if (isHeader || isFirstCol)
544-
cellRuns.forEach((run) => {
545-
if (run.properties) run.properties.bold = true;
546-
});
561+
const cellRuns = parseInlineFormatting(
562+
cell,
563+
cellTheme,
564+
isHeader ? { bold: true } : undefined
565+
);
547566
return new TableCell({
548567
borders,
549-
width: { size: colWidth, type: WidthType.DXA },
568+
width: { size: colWidths[colIdx], type: WidthType.DXA },
550569
shading: isHeader
551570
? { fill: t.colorTableHeader, type: ShadingType.CLEAR }
552571
: undefined,
@@ -631,7 +650,7 @@ export async function createDocument(metadata, elements, themeId = "kyotu", opti
631650
sections.push({
632651
properties: {
633652
page: {
634-
size: { width: 11906, height: 16838 },
653+
size: { width: A4_WIDTH_TWIPS, height: 16838 },
635654
margin: { top: 1440, right: 1440, bottom: 1440, left: 1440 },
636655
},
637656
},
@@ -643,7 +662,7 @@ export async function createDocument(metadata, elements, themeId = "kyotu", opti
643662
sections.push({
644663
properties: {
645664
page: {
646-
size: { width: 11906, height: 16838 },
665+
size: { width: A4_WIDTH_TWIPS, height: 16838 },
647666
margin: {
648667
top: t.marginPageTop,
649668
right: t.marginPage,
@@ -662,7 +681,7 @@ export async function createDocument(metadata, elements, themeId = "kyotu", opti
662681
sections.push({
663682
properties: {
664683
page: {
665-
size: { width: 11906, height: 16838 },
684+
size: { width: A4_WIDTH_TWIPS, height: 16838 },
666685
margin: {
667686
top: t.marginPageTop,
668687
right: t.marginPage,

tests/features/export/docx-quality.feature

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,22 @@ Feature: DOCX export quality
6161
Then the DOCX text content should contain "Item1"
6262
And the DOCX text content should contain "Item2"
6363

64+
Scenario: DOCX table header row is bold and columns are proportional
65+
Given the editor contains:
66+
"""
67+
---
68+
title: "Table Formatting"
69+
---
70+
71+
| ID | Full Description of the Feature | Status |
72+
| -- | ------------------------------- | ------ |
73+
| 1 | Implement user authentication | Done |
74+
| 2 | Add dashboard analytics view | WIP |
75+
"""
76+
When I export as "docx"
77+
Then the DOCX table header row should be bold
78+
And the DOCX table columns should have proportional widths
79+
6480
Scenario: DOCX HTML conversion preserves semantic structure
6581
Given the editor contains the file "full-document.md"
6682
When I export as "docx"

tests/helpers/docx-validator.js

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,44 @@ export class DocxValidator {
132132
return this.getParagraphs(doc).filter((p) => this.getParagraphStyle(p) === styleName);
133133
}
134134

135+
getTables(doc) {
136+
const body = doc["w:document"]?.["w:body"];
137+
if (!body) return [];
138+
return body["w:tbl"] || [];
139+
}
140+
141+
getTableCellText(cell) {
142+
return (cell["w:p"] || []).map((p) => this.getParagraphText(p)).join("");
143+
}
144+
145+
isRunBold(run) {
146+
const rPr = run["w:rPr"];
147+
if (!rPr) return false;
148+
const bold = rPr["w:b"];
149+
if (bold === undefined || bold === null) return false;
150+
if (typeof bold === "string") return bold !== "false";
151+
if (typeof bold === "object" && bold["@_w:val"] === "false") return false;
152+
return true;
153+
}
154+
155+
isCellBold(cell) {
156+
const runs = (cell["w:p"] || []).flatMap((p) => p["w:r"] || []);
157+
if (runs.length === 0) return false;
158+
return runs.every((r) => this.isRunBold(r));
159+
}
160+
161+
getTableColumnWidths(table) {
162+
const rows = table["w:tr"] || [];
163+
if (rows.length === 0) return [];
164+
const firstRow = rows[0];
165+
const cells = firstRow["w:tc"] || [];
166+
return cells.map((cell) => {
167+
const tcPr = cell["w:tcPr"];
168+
const tcW = tcPr?.["w:tcW"];
169+
return tcW ? parseInt(tcW["@_w:w"], 10) : 0;
170+
});
171+
}
172+
135173
getImageExtents(doc) {
136174
const paragraphs = this.getParagraphs(doc);
137175
const extents = [];

tests/steps/export.steps.js

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,28 @@ Then("the HTML should contain {int} {string} elements", async ({}, count, select
174174
expect($(selector).length).toBe(count);
175175
});
176176

177+
Then("the DOCX table header row should be bold", async ({}) => {
178+
const { doc } = await docxValidator.parse(lastDownloadBuffer);
179+
const tables = docxValidator.getTables(doc);
180+
expect(tables.length).toBeGreaterThan(0);
181+
const rows = tables[0]["w:tr"] || [];
182+
expect(rows.length).toBeGreaterThan(0);
183+
const headerCells = rows[0]["w:tc"] || [];
184+
for (const cell of headerCells) {
185+
expect(docxValidator.isCellBold(cell)).toBe(true);
186+
}
187+
});
188+
189+
Then("the DOCX table columns should have proportional widths", async ({}) => {
190+
const { doc } = await docxValidator.parse(lastDownloadBuffer);
191+
const tables = docxValidator.getTables(doc);
192+
expect(tables.length).toBeGreaterThan(0);
193+
const widths = docxValidator.getTableColumnWidths(tables[0]);
194+
expect(widths.length).toBeGreaterThan(1);
195+
const allEqual = widths.every((w) => w === widths[0]);
196+
expect(allEqual).toBe(false);
197+
});
198+
177199
Then("the DOCX should contain an image with correct aspect ratio", async ({}) => {
178200
const { doc, zip } = await docxValidator.parse(lastDownloadBuffer);
179201
const extents = docxValidator.getImageExtents(doc);

0 commit comments

Comments
 (0)