Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 34 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -203,8 +203,21 @@ const docx = await markdownToDocx(markdownText, {
// marginBottom: "2cm",
// marginLeft: 72,

// Font families (optional; uses Word defaults when unset)
bodyFont: "Times New Roman",
headingFont: "Arial",
codeFont: "Fira Code",
blockquoteFont: "Georgia",

// Remove blank-line paragraphs between content blocks
collapseEmptyLines: false,

// Image sizing: "actual" (use native size), "auto" (scale down only),
// or "fit" (scale to page bounds, may upscale). Respects page margins.
imageDefaultSize: "auto",

// Image horizontal alignment within the page
imageHorizontalAlign: "center",
}
})
```
Expand All @@ -215,7 +228,12 @@ All theme properties are optional — override only what you need. See [`example

| Property | Type | Default | Description |
|---|---|---|---|
| `heading1`–`heading6` | `string` | (various) | Hex color (no `#`) for each heading level |
| `heading1` | `string` | `"2F5597"` | Hex color (no `#`) for H1 |
| `heading2` | `string` | `"5B9BD5"` | Hex color (no `#`) for H2 |
| `heading3` | `string` | `"44546A"` | Hex color (no `#`) for H3 |
| `heading4` | `string` | `"44546A"` | Hex color (no `#`) for H4 |
| `heading5` | `string` | `"44546A"` | Hex color (no `#`) for H5 |
| `heading6` | `string` | `"44546A"` | Hex color (no `#`) for H6 |
| `link` | `string` | `"0563C1"` | Hex color for hyperlinks |
| `code` | `string` | `"032F62"` | Hex color for code blocks |
| `codespan` | `string` | `"70AD47"` | Hex color for inline code |
Expand All @@ -225,7 +243,15 @@ All theme properties are optional — override only what you need. See [`example
| `del` | `string` | `"FF0000"` | Hex color for strikethrough text |
| `hr` | `string` | `"D9D9D9"` | Hex color for horizontal rules |
| `html` | `string` | `"4472C4"` | Hex color for raw HTML blocks |
| `heading1Size`–`heading6Size` | `number` | 18/16/14/13/12/12 | Font size in pt for each heading |
| `tag` | `string` | `"ED7D31"` | Hex color for tags |
| `border` | `string` | `"A5A5A5"` | Hex color for table borders |
| `tableHeaderBackground` | `string` | `"F2F2F2"` | Background fill for table headers |
| `heading1Size` | `number` | `18` | H1 font size in pt |
| `heading2Size` | `number` | `16` | H2 font size in pt |
| `heading3Size` | `number` | `14` | H3 font size in pt |
| `heading4Size` | `number` | `13` | H4 font size in pt |
| `heading5Size` | `number` | `12` | H5 font size in pt |
| `heading6Size` | `number` | `12` | H6 font size in pt |
| `bodySize` | `number` | `12` | Base body font size in pt |
| `codeSize` | `number` | `11` | Font size in pt for code blocks |
| `lineSpacing` | `number` | `1.0` | Line spacing multiplier (e.g. `1.5` = 150%) |
Expand All @@ -236,6 +262,12 @@ All theme properties are optional — override only what you need. See [`example
| `marginBottom` | `number \| string` | — | Bottom margin; overrides `margin` |
| `marginLeft` | `number \| string` | — | Left margin; overrides `margin` |
| `collapseEmptyLines` | `boolean` | `false` | Omit blank-line separator paragraphs |
| `bodyFont` | `string` | — | Font family for body/paragraph text (e.g. `"Times New Roman"`). Uses Word default when unset. |
| `headingFont` | `string` | — | Font family for all heading levels (e.g. `"Arial"`). Uses Word default when unset. |
| `codeFont` | `string` | — | Font family for code blocks, inline code, HTML, and tags. Falls back to `"Courier New"` when unset. |
| `blockquoteFont` | `string` | — | Font family for blockquotes (e.g. `"Georgia"`). Uses Word default when unset. |
| `imageDefaultSize` | `"actual" \| "auto" \| "fit"` | `"actual"` | Image sizing: `"actual"` (native px), `"auto"` (scale down only to fit margins; never upscale), `"fit"` (scale to page bounds; may upscale) |
| `imageHorizontalAlign` | `"left" \| "center" \| "right"` | — | Horizontal alignment for images within the page |

#### Page margins

Expand Down
20 changes: 20 additions & 0 deletions bin/help-texts.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -47,9 +47,29 @@ SIZES & SPACING DEFAULT DESCRIPTION
spaceSize 6 Paragraph spacing (pt)
lineSpacing 1.0 Line spacing multiplier (1.0 = single, 1.5 = 150%)

MARGINS DEFAULT DESCRIPTION
margin (unset) CSS shorthand: 1 value=all, 2=t/b+l/r, 3=t+l/r+b, 4=t r b l. Units: pt (plain number), "cm", "in"/"inch"
marginTop (unset) Top margin; overrides shorthand. Number (pt) or "cm"/"in" string.
marginRight (unset) Right margin; overrides shorthand. Number (pt) or "cm"/"in" string.
marginBottom (unset) Bottom margin; overrides shorthand. Number (pt) or "cm"/"in" string.
marginLeft (unset) Left margin; overrides shorthand. Number (pt) or "cm"/"in" string.
collapseEmptyLines false Omit blank-line separator paragraphs

FONTS DEFAULT DESCRIPTION
bodyFont (unset) Body/paragraph font (e.g., "Times New Roman")
headingFont (unset) Font for all heading levels (e.g., "Arial")
codeFont (unset) Font for code blocks, inline code, HTML, tags (falls back to "Courier New")
blockquoteFont (unset) Font for blockquotes (e.g., "Georgia")

BOOLEANS DEFAULT DESCRIPTION
linkUnderline true Whether hyperlinks are underlined

ALIGNMENT DEFAULT DESCRIPTION
imageHorizontalAlign left Image horizontal alignment ("left", "center", or "right")

IMAGE SIZING DEFAULT DESCRIPTION
imageDefaultSize actual Image sizing behavior: "actual" (native size), "auto" (scale down to fit page, no upscale), "fit" (scale to fill page, may upscale)

See examples/sample-config.json for a ready-to-use config file.
`.trim()

Expand Down
10 changes: 8 additions & 2 deletions examples/sample-config.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,12 @@
"marginRight": null,
"marginBottom": null,
"marginLeft": null,
"collapseEmptyLines": false
"collapseEmptyLines": false,
"bodyFont": "Times New Roman",
"headingFont": "Arial",
"codeFont": "Fira Code",
"blockquoteFont": "Georgia",
"imageHorizontalAlign": "center",
"imageDefaultSize": "auto"
}
}
}
4 changes: 2 additions & 2 deletions src/MarkdownDocx.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Document, FileChild, IPropertiesOptions, IStylesOptions, Paragraph, ParagraphChild } from 'docx'
import { Document, FileChild, IPageMarginAttributes, IPropertiesOptions, IStylesOptions, Paragraph, ParagraphChild } from 'docx'
import { Tokens } from 'marked'

import { renderBlocks, renderTokens } from './renders'
Expand Down Expand Up @@ -57,7 +57,7 @@ export class MarkdownDocx {

const section = await this.toSection()
const pageMargins = this.options.theme ? resolvePageMargins(this.options.theme) : null
const sectionProperties = pageMargins ? { page: { margin: pageMargins } } : undefined
const sectionProperties = pageMargins ? { page: { margin: pageMargins as IPageMarginAttributes } } : undefined
const doc = new Document({
numbering,
styles: createDocumentStyle({ theme: this.options.theme}),
Expand Down
94 changes: 90 additions & 4 deletions src/renders/render-image.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,28 @@ import { Tokens } from 'marked'

import { MarkdownDocx } from '../MarkdownDocx'
import { ITextAttr, MarkdownImageItem } from '../types'
import { getUsablePageDimensions } from '../utils'
import { renderText } from './render-text'

/**
* Default maximum image dimensions in "pixels" (at 96 DPI) for an A4 page
* with 1" margins. These are used when no theme/margin configuration is
* provided; when the theme specifies custom margins, the actual usable area
* is computed via `getUsablePageDimensions()`.
*
* A4 = 210mm × 297mm → 8.27" × 11.69" at 96 DPI.
* Subtract 1" margin on each side (2" total):
* Usable width: 8.27" - 2" = 6.27" → 6.27 × 96 ≈ 602px
* Usable height: 11.69" - 2" = 9.69" → 9.69 × 96 ≈ 931px
*
* - "auto": scale down only when either dimension overflows; never upscale.
* - "fit": scale so one axis reaches exactly its max (may upscale small images).
* Like Word's "fit to page".
*/
const DEFAULT_MAX_IMAGE_WIDTH_PX = 602
const DEFAULT_MAX_IMAGE_HEIGHT_PX = 931


export function renderImage(render: MarkdownDocx, block: Tokens.Image, attr: ITextAttr) {
if (render.ignoreImage) {
return false
Expand All @@ -16,12 +36,24 @@ export function renderImage(render: MarkdownDocx, block: Tokens.Image, attr: ITe
return renderText(render, `[!${block.text}](${block.href})`, attr)
}

const { width, height, title } = parseImageTitleSize(block, image)
const { width, height, title, isExplicitSize } = parseImageTitleSize(block, image)

const theme = render.options.theme
const imageDefaultSize = theme?.imageDefaultSize ?? "actual"

// Compute max dimensions from the theme's page margins (falls back to
// default A4 + 1" margins when no margins are configured).
const { maxWidthPx, maxHeightPx } = getUsablePageDimensions(theme)

const finalSize = scaleImageToFit(
width, height, imageDefaultSize, isExplicitSize,
maxWidthPx, maxHeightPx,
)

return new ImageRun({
type: image.type,
data: image.data,
transformation: { width, height },
transformation: { width: finalSize.width, height: finalSize.height },
altText: {
title: title || block.text,
description: block.text,
Expand All @@ -44,7 +76,8 @@ export function parseImageTitleSize(block: Tokens.Image, image: MarkdownImageIte
return {
width: image.width,
height: image.height,
title: block.title
title: block.title,
isExplicitSize: false,
}
}

Expand All @@ -55,6 +88,59 @@ export function parseImageTitleSize(block: Tokens.Image, image: MarkdownImageIte
width,
height,
// remove title
title: ''
title: '',
isExplicitSize: true,
}
}

/**
* Scale image dimensions to fit within page margins when imageDefaultSize
* is "auto" or "fit".
*
* - "auto": Scale down only when either dimension exceeds its max.
* Never upscale. Both width and height are constrained.
* - "fit": Scale so one axis reaches its max value (may upscale small
* images). Like Word's "fit to page".
*
* Explicit sizes (from the title attribute) are never scaled.
*
* @param maxWidthPx Usable page width in pixels at 96 DPI
* @param maxHeightPx Usable page height in pixels at 96 DPI
*/
export function scaleImageToFit(
width: number,
height: number,
imageDefaultSize: "actual" | "auto" | "fit",
isExplicitSize: boolean = false,
maxWidthPx: number = DEFAULT_MAX_IMAGE_WIDTH_PX,
maxHeightPx: number = DEFAULT_MAX_IMAGE_HEIGHT_PX,
): { width: number; height: number } {
if (imageDefaultSize === "actual" || isExplicitSize || width <= 0 || height <= 0) {
return { width, height }
}

if (imageDefaultSize === "auto") {
// Constrain both width AND height to ≤ max. Scale down only; never upscale.
if (width <= maxWidthPx && height <= maxHeightPx) {
return { width, height }
}
const ratioW = maxWidthPx / width
const ratioH = maxHeightPx / height
const ratio = Math.min(ratioW, ratioH)
return {
width: Math.round(width * ratio),
height: Math.round(height * ratio),
}
}

// "fit" mode: scale so one axis reaches exactly its max value.
// The other axis will be ≤ its max. May scale up small images.
const ratioW = maxWidthPx / width
const ratioH = maxHeightPx / height
const ratio = Math.min(ratioW, ratioH)

return {
width: Math.round(width * ratio),
height: Math.round(height * ratio),
}
}
8 changes: 6 additions & 2 deletions src/renders/render-paragraph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,13 @@ import { renderCheckbox } from './render-checkbox'
import { renderText } from './render-text'
import { renderTokens } from './render-tokens'

export function renderParagraph (render: MarkdownDocx, tokens: IInlineToken[] | string, attr: IBlockAttr) {
export function renderParagraph(render: MarkdownDocx, tokens: IInlineToken[] | string, attr: IBlockAttr) {
const heading = getHeadingLevel(attr.heading)
const alignment = getTextAlignment(attr.align)

let alignment = getTextAlignment(attr.align);
if (!alignment && typeof tokens !== "string" && tokens.length === 1 && tokens[0]!.type === "image") {
alignment = getTextAlignment(render.options.theme?.imageHorizontalAlign)
}

const hasList = !attr.listNone && attr.list

Expand Down
17 changes: 12 additions & 5 deletions src/styles/markdown.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { defaultTheme } from './themes'

export const createMarkdownStyle = (_theme: Partial<IMarkdownTheme>): Record<IMarkdownToken, IMarkdownStyle> => {
const theme: IMarkdownTheme = { ...defaultTheme, ..._theme }

return {
space: {
className: classes.Space,
Expand All @@ -20,7 +20,7 @@ export const createMarkdownStyle = (_theme: Partial<IMarkdownTheme>): Record<IMa
code: {
className: classes.Code,
run: {
font: "Courier New",
font: theme.codeFont || "Courier New",
size: theme.codeSize * 2, // convert pt to half-points
color: theme.code,
},
Expand Down Expand Up @@ -80,6 +80,7 @@ export const createMarkdownStyle = (_theme: Partial<IMarkdownTheme>): Record<IMa
blockquote: {
className: classes.Blockquote,
run: {
font: theme.blockquoteFont,
color: theme.blockquote,
italics: true,
},
Expand Down Expand Up @@ -107,7 +108,7 @@ export const createMarkdownStyle = (_theme: Partial<IMarkdownTheme>): Record<IMa
html: {
className: classes.Html,
run: {
font: "Courier New",
font: theme.codeFont || "Courier New",
color: theme.html,
},
},
Expand Down Expand Up @@ -178,6 +179,7 @@ export const createMarkdownStyle = (_theme: Partial<IMarkdownTheme>): Record<IMa
heading1: {
className: classes.Heading1,
run: {
font: theme.headingFont,
size: theme.heading1Size * 2, // convert pt to half-points
bold: true,
color: theme.heading1,
Expand All @@ -194,6 +196,7 @@ export const createMarkdownStyle = (_theme: Partial<IMarkdownTheme>): Record<IMa
heading2: {
className: classes.Heading2,
run: {
font: theme.headingFont,
size: theme.heading2Size * 2, // convert pt to half-points
bold: true,
color: theme.heading2,
Expand All @@ -210,6 +213,7 @@ export const createMarkdownStyle = (_theme: Partial<IMarkdownTheme>): Record<IMa
heading3: {
className: classes.Heading3,
run: {
font: theme.headingFont,
size: theme.heading3Size * 2, // convert pt to half-points
bold: true,
color: theme.heading3,
Expand All @@ -226,6 +230,7 @@ export const createMarkdownStyle = (_theme: Partial<IMarkdownTheme>): Record<IMa
heading4: {
className: classes.Heading4,
run: {
font: theme.headingFont,
size: theme.heading4Size * 2, // convert pt to half-points
bold: true,
color: theme.heading4,
Expand All @@ -242,6 +247,7 @@ export const createMarkdownStyle = (_theme: Partial<IMarkdownTheme>): Record<IMa
heading5: {
className: classes.Heading5,
run: {
font: theme.headingFont,
size: theme.heading5Size * 2, // convert pt to half-points
bold: true,
italics: true,
Expand All @@ -259,6 +265,7 @@ export const createMarkdownStyle = (_theme: Partial<IMarkdownTheme>): Record<IMa
heading6: {
className: classes.Heading6,
run: {
font: theme.headingFont,
size: theme.heading6Size * 2, // convert pt to half-points
bold: false,
italics: true,
Expand All @@ -279,7 +286,7 @@ export const createMarkdownStyle = (_theme: Partial<IMarkdownTheme>): Record<IMa
inline: true,
className: classes.Tag,
run: {
font: "Courier New",
font: theme.codeFont || "Courier New",
color: theme.tag,
},
},
Expand Down Expand Up @@ -311,7 +318,7 @@ export const createMarkdownStyle = (_theme: Partial<IMarkdownTheme>): Record<IMa
inline: true,
className: classes.Codespan,
run: {
font: "Courier New",
font: theme.codeFont || "Courier New",
color: theme.codespan,
},
},
Expand Down
1 change: 1 addition & 0 deletions src/styles/styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export function createDefaultStyle(theme: IMarkdownTheme): IStylesOptions['defau
document: {
run: {
size: (theme.bodySize ?? defaultTheme.bodySize ?? 12) * 2, // Convert pt to half-points
font: theme.bodyFont,
},
paragraph: {
spacing: {
Expand Down
Loading