diff --git a/README.md b/README.md index 24aa3c8..1cfecf7 100644 --- a/README.md +++ b/README.md @@ -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", } }) ``` @@ -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 | @@ -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%) | @@ -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 diff --git a/bin/help-texts.mjs b/bin/help-texts.mjs index 6147c1b..10c7d96 100644 --- a/bin/help-texts.mjs +++ b/bin/help-texts.mjs @@ -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() diff --git a/examples/sample-config.json b/examples/sample-config.json index a8decf2..fc7518a 100644 --- a/examples/sample-config.json +++ b/examples/sample-config.json @@ -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" } -} +} \ No newline at end of file diff --git a/src/MarkdownDocx.ts b/src/MarkdownDocx.ts index 0026108..c02c944 100644 --- a/src/MarkdownDocx.ts +++ b/src/MarkdownDocx.ts @@ -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' @@ -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}), diff --git a/src/renders/render-image.ts b/src/renders/render-image.ts index e57f04a..fd07688 100644 --- a/src/renders/render-image.ts +++ b/src/renders/render-image.ts @@ -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 @@ -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, @@ -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, } } @@ -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), } } diff --git a/src/renders/render-paragraph.ts b/src/renders/render-paragraph.ts index 8bdc51f..049dc7c 100644 --- a/src/renders/render-paragraph.ts +++ b/src/renders/render-paragraph.ts @@ -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 diff --git a/src/styles/markdown.ts b/src/styles/markdown.ts index 92edcc1..bcf49da 100644 --- a/src/styles/markdown.ts +++ b/src/styles/markdown.ts @@ -6,7 +6,7 @@ import { defaultTheme } from './themes' export const createMarkdownStyle = (_theme: Partial): Record => { const theme: IMarkdownTheme = { ...defaultTheme, ..._theme } - + return { space: { className: classes.Space, @@ -20,7 +20,7 @@ export const createMarkdownStyle = (_theme: Partial): Record): Record): Record): Record): Record): Record): Record): Record): Record): Record): Record 1 cm = 1440/2.54 ≈ 566.93 twips const CM_TO_TWIPS = (1440 / 2.54) +// A4 dimensions in twips (used when computing usable page area for images) +const A4_WIDTH_TWIPS = 11906 // 210 mm +const A4_HEIGHT_TWIPS = 16838 // 297 mm +// Word default page margins: 1 inch on all sides +const DEFAULT_MARGIN_TWIPS = 1440 function parseMarginValue(val: string | number): number { if (typeof val === 'number') return Math.round(val * PT_TO_TWIPS) @@ -144,11 +149,19 @@ function parseMarginValue(val: string | number): number { return isNaN(num) ? 0 : Math.round(num * PT_TO_TWIPS) } +/** Simple internal type for resolved page margins in twips (plain numbers). */ +export interface ResolvedPageMargins { + top?: number + right?: number + bottom?: number + left?: number +} + /** * Resolve page margin twip values from a theme's margin properties. * Returns null when no margin properties are set. */ -export function resolvePageMargins(theme: Partial): IPageMarginAttributes | null { +export function resolvePageMargins(theme: Partial): ResolvedPageMargins | null { const hasShorthand = theme.margin != null const hasVerbose = theme.marginTop != null || @@ -158,12 +171,7 @@ export function resolvePageMargins(theme: Partial): IPageMarginA if (!hasShorthand && !hasVerbose) return null - const margins: { - top?: number - right?: number - bottom?: number - left?: number - } = {} + const margins: ResolvedPageMargins = {} if (hasShorthand) { const m = theme.margin! @@ -192,5 +200,36 @@ export function resolvePageMargins(theme: Partial): IPageMarginA if (theme.marginBottom != null) margins.bottom = parseMarginValue(theme.marginBottom) if (theme.marginLeft != null) margins.left = parseMarginValue(theme.marginLeft) - return margins as IPageMarginAttributes + return margins +} + +/** + * Compute usable page dimensions (width and height in pixels at 96 DPI) + * from the theme's margin settings. + * + * Falls back to Word's default 1-inch margins on A4 when no margin properties + * are configured. Used by image auto/fit scaling to determine the maximum + * image size that fits within the document's page margins. + */ +export function getUsablePageDimensions(theme?: Partial): { + maxWidthPx: number + maxHeightPx: number +} { + const margins = theme ? resolvePageMargins(theme) : null + + // Use explicit margins if set, otherwise fall back to Word default 1" margins + const marginLeft = margins?.left ?? DEFAULT_MARGIN_TWIPS + const marginRight = margins?.right ?? DEFAULT_MARGIN_TWIPS + const marginTop = margins?.top ?? DEFAULT_MARGIN_TWIPS + const marginBottom = margins?.bottom ?? DEFAULT_MARGIN_TWIPS + + // Usable area in twips + const usableWidthTwips = A4_WIDTH_TWIPS - marginLeft - marginRight + const usableHeightTwips = A4_HEIGHT_TWIPS - marginTop - marginBottom + + // Convert twips → inches → pixels at 96 DPI (1 twip = 1/1440 inch) + const maxWidthPx = Math.round((usableWidthTwips / IN_TO_TWIPS) * 96) + const maxHeightPx = Math.round((usableHeightTwips / IN_TO_TWIPS) * 96) + + return { maxWidthPx, maxHeightPx } } diff --git a/tests/index.test.ts b/tests/index.test.ts index 4674516..1f64c10 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -49,6 +49,14 @@ describe('markdown-docx', () => { spaceSize: 18, codeSize: 20, linkUnderline: false, + bodyFont: "Times New Roman", + headingFont: "Arial", + codeFont: "Fira Code", + blockquoteFont: "Georgia", + imageHorizontalAlign: "center", + imageDefaultSize: "fit", + marginLeft: "2inch", + marginRight: "3inch" } }) diff --git a/tests/list-complex.docx b/tests/list-complex.docx index ef45cc1..b2253e5 100644 Binary files a/tests/list-complex.docx and b/tests/list-complex.docx differ diff --git a/tests/list-number-restart.docx b/tests/list-number-restart.docx index 1bcc813..3a7ea46 100644 Binary files a/tests/list-number-restart.docx and b/tests/list-number-restart.docx differ diff --git a/tests/markdown.docx b/tests/markdown.docx index 185da87..466b271 100644 Binary files a/tests/markdown.docx and b/tests/markdown.docx differ diff --git a/tests/math-example-from-file.docx b/tests/math-example-from-file.docx index cd4142d..ed65fa3 100644 Binary files a/tests/math-example-from-file.docx and b/tests/math-example-from-file.docx differ diff --git a/tests/test-math-comprehensive.docx b/tests/test-math-comprehensive.docx index 4641f21..adab67a 100644 Binary files a/tests/test-math-comprehensive.docx and b/tests/test-math-comprehensive.docx differ diff --git a/tests/theme-CLI.test.ts b/tests/theme-CLI.test.ts index 0b35aa0..470f01e 100644 --- a/tests/theme-CLI.test.ts +++ b/tests/theme-CLI.test.ts @@ -98,6 +98,7 @@ describe('CLI help subcommand', () => { expect(output).toContain('bodySize') expect(output).toContain('linkUnderline') expect(output).toContain('sample-config.json') + expect(output).toContain('bodyFont') }) it('markdown-docx help config outputs config field names', () => { diff --git a/tests/theme-styles.test.ts b/tests/theme-styles.test.ts index 423f207..0ff1c27 100644 --- a/tests/theme-styles.test.ts +++ b/tests/theme-styles.test.ts @@ -1,7 +1,16 @@ -import { describe, it, expect } from 'vitest' +import { describe, it, expect, vi } from 'vitest' +import { ImageRun, Paragraph } from 'docx' +import { Tokens } from 'marked' import { createDefaultStyle } from '../src/styles/styles' +import { createMarkdownStyle } from '../src/styles/markdown' import { IMarkdownTheme } from '../src/types' import { defaultTheme } from '../src/styles/themes' +import { MarkdownDocx } from '../src/MarkdownDocx' +import { renderImage, scaleImageToFit } from '../src/renders/render-image' +import { renderParagraph } from '../src/renders/render-paragraph' +import { MarkdownImageItem } from '../src/types' + +vi.mock('docx') describe('createDefaultStyle', () => { it('converts bodySize (pt) to half-points for docx', () => { @@ -52,3 +61,400 @@ describe('createDefaultStyle', () => { expect(style2.document?.paragraph?.spacing?.line).toBe(240) // 1.0 default → 240 twips }) }) + +describe('createMarkdownStyle font-family', () => { + const defaultStyles = createMarkdownStyle({}) + + it('code font defaults to "Courier New" when codeFont is not set', () => { + expect(defaultStyles.code.run?.font).toBe('Courier New') + }) + + it('code font uses custom codeFont when set', () => { + const styles = createMarkdownStyle({ codeFont: 'Fira Code' }) + expect(styles.code.run?.font).toBe('Fira Code') + }) + + it('codespan font defaults to "Courier New" when codeFont is not set', () => { + expect(defaultStyles.codespan.run?.font).toBe('Courier New') + }) + + it('codespan font uses custom codeFont when set', () => { + const styles = createMarkdownStyle({ codeFont: 'JetBrains Mono' }) + expect(styles.codespan.run?.font).toBe('JetBrains Mono') + }) + + it('html font defaults to "Courier New" when codeFont is not set', () => { + expect(defaultStyles.html.run?.font).toBe('Courier New') + }) + + it('tag font defaults to "Courier New" when codeFont is not set', () => { + expect(defaultStyles.tag.run?.font).toBe('Courier New') + }) + + it('tag font uses custom codeFont when set', () => { + const styles = createMarkdownStyle({ codeFont: 'Source Code Pro' }) + expect(styles.tag.run?.font).toBe('Source Code Pro') + }) + + it('heading font is omitted when headingFont is not set', () => { + expect(defaultStyles.heading1.run?.font).toBeUndefined() + expect(defaultStyles.heading2.run?.font).toBeUndefined() + expect(defaultStyles.heading3.run?.font).toBeUndefined() + expect(defaultStyles.heading4.run?.font).toBeUndefined() + expect(defaultStyles.heading5.run?.font).toBeUndefined() + expect(defaultStyles.heading6.run?.font).toBeUndefined() + }) + + it('heading font is applied to all heading levels when headingFont is set', () => { + const styles = createMarkdownStyle({ headingFont: 'Georgia' }) + expect(styles.heading1.run?.font).toBe('Georgia') + expect(styles.heading2.run?.font).toBe('Georgia') + expect(styles.heading3.run?.font).toBe('Georgia') + expect(styles.heading4.run?.font).toBe('Georgia') + expect(styles.heading5.run?.font).toBe('Georgia') + expect(styles.heading6.run?.font).toBe('Georgia') + }) + + it('blockquote font is omitted when blockquoteFont is not set', () => { + expect(defaultStyles.blockquote.run?.font).toBeUndefined() + }) + + it('blockquote font is applied when blockquoteFont is set', () => { + const styles = createMarkdownStyle({ blockquoteFont: 'Georgia' }) + expect(styles.blockquote.run?.font).toBe('Georgia') + }) + + it('paragraph font is omitted when bodyFont is not set (body font handled at document level)', () => { + expect(defaultStyles.paragraph.run?.font).toBeUndefined() + }) + + it('combines multiple font theme options', () => { + const styles = createMarkdownStyle({ + headingFont: 'Arial', + codeFont: 'Fira Code', + blockquoteFont: 'Times New Roman', + }) + expect(styles.heading1.run?.font).toBe('Arial') + expect(styles.code.run?.font).toBe('Fira Code') + expect(styles.blockquote.run?.font).toBe('Times New Roman') + // code-related fonts all use codeFont + expect(styles.codespan.run?.font).toBe('Fira Code') + expect(styles.html.run?.font).toBe('Fira Code') + expect(styles.tag.run?.font).toBe('Fira Code') + }) +}) + +// --------------------------------------------------------------------------- +// Helpers for image rendering tests +// --------------------------------------------------------------------------- + +function createMockImageToken(overrides: Partial = {}): Tokens.Image { + return { + type: 'image', + raw: '![test](https://example.com/test.png)', + href: 'https://example.com/test.png', + title: '', + text: 'test image', + ...overrides, + } +} + +function createMockImageItem(overrides: Partial = {}): MarkdownImageItem { + return { + type: 'png', + data: Buffer.from('fake-image-data'), + width: 100, + height: 200, + ...overrides, + } +} + +function createImageRenderer(themeOptions: Partial = {}) { + const renderer = new MarkdownDocx('', { theme: themeOptions }) + renderer['_imageStore'].set('https://example.com/test.png', createMockImageItem()) + return renderer +} + +// --------------------------------------------------------------------------- +// renderParagraph – imageHorizontalAlign integration +// --------------------------------------------------------------------------- + +describe('renderParagraph imageHorizontalAlign', () => { + + it('wraps single image in center-aligned Paragraph when imageHorizontalAlign is "center"', () => { + const render = createImageRenderer({ imageHorizontalAlign: 'center' }) + const result = renderParagraph(render, [createMockImageToken()] as any, {}) + expect(result).toBeInstanceOf(Paragraph) + expect(result!.toString()).toContain('alignment="center"') + expect(result!.toString()).toContain(' { + const render = createImageRenderer({ imageHorizontalAlign: 'right' }) + const result = renderParagraph(render, [createMockImageToken()] as any, {}) + expect(result).toBeInstanceOf(Paragraph) + expect(result!.toString()).toContain('alignment="right"') + }) + + it('does not set alignment when imageHorizontalAlign is not configured', () => { + const render = createImageRenderer() + const result = renderParagraph(render, [createMockImageToken()] as any, {}) + expect(result!.toString()).not.toContain('alignment=') + }) + + it('explicit attr.align takes precedence over imageHorizontalAlign', () => { + const render = createImageRenderer({ imageHorizontalAlign: 'center' }) + const result = renderParagraph(render, [createMockImageToken()] as any, { align: 'right' }) + expect(result!.toString()).toContain('alignment="right"') + }) + + it('ignores imageHorizontalAlign when tokens are not a single image', () => { + const render = createImageRenderer({ imageHorizontalAlign: 'center' }) + const result = renderParagraph(render, 'plain text', {}) + expect(result!.toString()).not.toContain('alignment=') + }) + + it('ignores imageHorizontalAlign when there are multiple tokens', () => { + const render = createImageRenderer({ imageHorizontalAlign: 'center' }) + const result = renderParagraph(render, [createMockImageToken(), createMockImageToken()] as any, {}) + expect(result!.toString()).not.toContain('alignment=') + }) +}) + +// --------------------------------------------------------------------------- +// renderImage – basic behavior (alignment is tested above via renderParagraph) +// --------------------------------------------------------------------------- + +describe('renderImage', () => { + + it('returns false when ignoreImage is true', () => { + const renderer = new MarkdownDocx('', { ignoreImage: true }) + const result = renderImage(renderer, createMockImageToken(), {}) + expect(result).toBe(false) + }) + + it('falls back to text when image is not in store', () => { + const renderer = new MarkdownDocx('', {}) + const result = renderImage(renderer, createMockImageToken(), {}) + expect(Array.isArray(result)).toBe(true) + }) + + it('produces ImageRun with altText from token text', () => { + const render = createImageRenderer() + const result = renderImage(render, createMockImageToken({ text: 'alt text' }), {}) + expect(result).toBeInstanceOf(ImageRun) + expect(result!.toString()).toContain('altText-title="alt text"') + }) +}) + +// --------------------------------------------------------------------------- +// scaleImageToFit (max: 602×931 px — A4 with 1" margins at 96 DPI) +// --------------------------------------------------------------------------- + +describe('scaleImageToFit', () => { + it('returns original dimensions when imageDefaultSize is "actual"', () => { + const result = scaleImageToFit(1200, 800, "actual") + expect(result.width).toBe(1200) + expect(result.height).toBe(800) + }) + + it('returns original dimensions when imageDefaultSize is "auto" but image is within bounds', () => { + const result = scaleImageToFit(400, 300, "auto") + expect(result.width).toBe(400) + expect(result.height).toBe(300) + }) + + it('returns original dimensions when imageDefaultSize is "auto" and image exactly at max width', () => { + const result = scaleImageToFit(602, 400, "auto") + expect(result.width).toBe(602) + expect(result.height).toBe(400) + }) + + it('scales down by width when image is wider than max', () => { + // 1200×800 → ratioW=602/1200=0.5017, ratioH=931/800=1.1638 → use width + const result = scaleImageToFit(1200, 800, "auto") + expect(result.width).toBe(602) + expect(result.height).toBe(401) // 800 * 602/1200 = 401.33 → 401 + }) + + it('scales down proportionally preserving aspect ratio', () => { + // 1800×600 → ratioW=602/1800=0.3344, ratioH=931/600=1.5517 → use width + const result = scaleImageToFit(1800, 600, "auto") + expect(result.width).toBe(602) + expect(result.height).toBe(201) // 600 * 602/1800 = 200.67 → 201 + }) + + it('scales down tall images by height constraint in "auto" mode', () => { + // "auto" constrains both axes. 800×2000: ratioW=602/800=0.7525, ratioH=931/2000=0.4655 → use height + const result = scaleImageToFit(800, 2000, "auto") + expect(result.width).toBe(372) // 800 * 931/2000 = 372.4 → 372 + expect(result.height).toBe(931) + }) + + it('rounds dimensions to integers', () => { + // 1000×753 → ratioW=602/1000=0.602, ratioH=931/753=1.2364 → use width + const result = scaleImageToFit(1000, 753, "auto") + expect(result.width).toBe(602) + expect(result.height).toBe(453) // Math.round(753 * 602/1000) = Math.round(453.306) = 453 + }) + + it('returns original dimensions when isExplicitSize is true (regardless of mode)', () => { + const result = scaleImageToFit(2000, 1500, "auto", true) + expect(result.width).toBe(2000) + expect(result.height).toBe(1500) + }) + + it('isExplicitSize takes precedence even when mode is "auto"', () => { + const result = scaleImageToFit(2000, 1500, "auto", true) + expect(result.width).toBe(2000) + expect(result.height).toBe(1500) + }) + + // ---- "fit" mode: scale to fill bounds (may upscale) ---- + + it('upscales small images in "fit" mode to fill one axis', () => { + // 600×400 → ratioW=602/600=1.0033, ratioH=931/400=2.3275 → use width + const result = scaleImageToFit(600, 400, "fit") + expect(result.width).toBe(602) + expect(result.height).toBe(401) + }) + + it('scales down by width when width is the more constraining axis', () => { + // 1200×800 → ratioW=0.5017, ratioH=1.1638 → use width + const result = scaleImageToFit(1200, 800, "fit") + expect(result.width).toBe(602) + expect(result.height).toBe(401) + }) + + it('scales down by height when height is the more constraining axis', () => { + // 600×1200 → ratioW=602/600=1.0033, ratioH=931/1200=0.7758 → use height + const result = scaleImageToFit(600, 1200, "fit") + expect(result.width).toBe(466) // 600 * 931/1200 = 465.5 → 466 + expect(result.height).toBe(931) + }) + + it('scales down a square image that exceeds both width and height equally', () => { + // 1000×1000 → ratioW=0.602, ratioH=0.931 → use width + const result = scaleImageToFit(1000, 1000, "fit") + expect(result.width).toBe(602) + expect(result.height).toBe(602) + }) + + it('scales by width when image is wider than max but height is exactly at max', () => { + // 1248×931 → ratioW=602/1248=0.4824, ratioH=931/931=1.0 → use width + const result = scaleImageToFit(1248, 931, "fit") + expect(result.width).toBe(602) + expect(result.height).toBe(449) // 931 * 602/1248 = 449.26 → 449 + }) + + it('scales by height when image is taller than max but width is exactly at max', () => { + // 602×1728 → ratioW=1.0, ratioH=931/1728=0.5388 → use height + const result = scaleImageToFit(602, 1728, "fit") + expect(result.width).toBe(324) // 602 * 931/1728 = 324.42 → 324 + expect(result.height).toBe(931) + }) + + it('upscales small images in "fit" mode when height is the more constraining axis', () => { + // 300×500 → ratioW=602/300=2.0067, ratioH=931/500=1.862 → use height + const result = scaleImageToFit(300, 500, "fit") + expect(result.width).toBe(559) // 300 * 931/500 = 558.6 → 559 + expect(result.height).toBe(931) + }) + + it('rounds dimensions to integers in "fit" mode', () => { + // 1000×753 → ratioW=0.602, ratioH=1.2364 → use width + const result = scaleImageToFit(1000, 753, "fit") + expect(result.width).toBe(602) + expect(result.height).toBe(453) // Math.round(753 * 602/1000) = 453 + }) +}) + +// --------------------------------------------------------------------------- +// renderImage – imageDefaultSize integration +// --------------------------------------------------------------------------- + +describe('renderImage imageDefaultSize', () => { + it('uses actual width when theme does not set imageDefaultSize (defaults to "actual")', () => { + const render = createImageRenderer() + const result = renderImage(render, createMockImageToken(), {}) + expect(result).toBeInstanceOf(ImageRun) + expect(result!.toString()).toContain('width="100"') + expect(result!.toString()).toContain('height="200"') + }) + + it('uses actual width when imageDefaultSize is "actual"', () => { + const render = createImageRenderer({ imageDefaultSize: 'actual' }) + const result = renderImage(render, createMockImageToken(), {}) + expect(result).toBeInstanceOf(ImageRun) + expect(result!.toString()).toContain('width="100"') + expect(result!.toString()).toContain('height="200"') + }) + + it('leaves small images unchanged in "auto" mode', () => { + const render = createImageRenderer({ imageDefaultSize: 'auto' }) + const result = renderImage(render, createMockImageToken(), {}) + expect(result).toBeInstanceOf(ImageRun) + expect(result!.toString()).toContain('width="100"') + expect(result!.toString()).toContain('height="200"') + }) + + it('scales down large images in "auto" mode', () => { + const render = createImageRenderer({ imageDefaultSize: 'auto' }) + render['_imageStore'].set('https://example.com/test.png', createMockImageItem({ + width: 1200, + height: 800, + })) + const result = renderImage(render, createMockImageToken(), {}) + expect(result).toBeInstanceOf(ImageRun) + expect(result!.toString()).toContain('width="602"') + expect(result!.toString()).toContain('height="401"') + }) + + it('does not scale image when title has explicit size (even in "auto" mode)', () => { + const render = createImageRenderer({ imageDefaultSize: 'auto' }) + render['_imageStore'].set('https://example.com/sized.png', createMockImageItem({ + width: 1200, + height: 800, + })) + const result = renderImage(render, createMockImageToken({ + href: 'https://example.com/sized.png', + title: '600x400', + }), {}) + expect(result).toBeInstanceOf(ImageRun) + expect(result!.toString()).toContain('width="600"') + expect(result!.toString()).toContain('height="400"') + }) + + it('upscales small images in "fit" mode', () => { + // 100×200 → ratioW=6.02, ratioH=4.655 → use height → 466×931 + const render = createImageRenderer({ imageDefaultSize: 'fit' }) + const result = renderImage(render, createMockImageToken(), {}) + expect(result).toBeInstanceOf(ImageRun) + expect(result!.toString()).toContain('width="466"') + expect(result!.toString()).toContain('height="931"') + }) + + it('scales down wide image by width in "fit" mode', () => { + const render = createImageRenderer({ imageDefaultSize: 'fit' }) + render['_imageStore'].set('https://example.com/test.png', createMockImageItem({ + width: 1200, + height: 800, + })) + const result = renderImage(render, createMockImageToken(), {}) + expect(result).toBeInstanceOf(ImageRun) + expect(result!.toString()).toContain('width="602"') + expect(result!.toString()).toContain('height="401"') + }) + + it('scales down tall image by height in "fit" mode', () => { + const render = createImageRenderer({ imageDefaultSize: 'fit' }) + render['_imageStore'].set('https://example.com/test.png', createMockImageItem({ + width: 600, + height: 1200, + })) + const result = renderImage(render, createMockImageToken(), {}) + expect(result).toBeInstanceOf(ImageRun) + expect(result!.toString()).toContain('width="466"') + expect(result!.toString()).toContain('height="931"') + }) +})