diff --git a/PLAN.md b/PLAN.md index df4350ff..fbd5e935 100644 --- a/PLAN.md +++ b/PLAN.md @@ -28,12 +28,12 @@ container、md2pdf pagination / block layout、workspace 運用の順で磨く ### 1. `@pdfme/jsx` layout / dynamic container フォローアップ -- Form 入力や dynamic layout reflow で `Text` / MVT が runtime に `overflow: "expand"` した場合、 - JSX の親 `Box` / container は自動では広がらない。生成後の `Box` は rectangle schema であり、 - 子 schema の実描画高さと親 container を結び直す contract がまだないため。親子 container dynamic - layout は実装したい重要課題として扱う。 -- まずは JSX から生成された visual `Box` と子 text/MVT の関係を metadata として template に残す案と、 - 単一 text/MVT 子の decoration に畳む案を比較する。 +- JSX から生成された auto-height visual `Box` は、子 schema 名を内部 metadata として持たせ、Form/Viewer + や generator の dynamic layout reflow 時に `overflow: "expand"` した子 text/MVT の高さへ追従する。 +- 現在の対象は同一ページ内の parent/child decoration reflow。child schema が page break を跨いで split + する場合、child は分割できるが、親 `Box` decoration はまだ multi-page container としては分割しない。 +- 次に必要なら、multi-page container decoration、nested container の厳密な layout tree、単一 text/MVT 子の + decoration 畳み込みを検討する。 - CSS/Flexbox 互換を目指さず、flexbox の使いやすさだけを `Stack` / `Row` に取り込む。 - `flexWrap`, `flexShrink`, media query, full `style` prop, CSS parser は当面対象外。 - `%` width は将来検討でよい。まずは `flex` / `flexGrow` で比率指定を表現する。 diff --git a/packages/common/__tests__/dynamicTemplate.test.ts b/packages/common/__tests__/dynamicTemplate.test.ts index 4d7f5937..a00e1bfc 100644 --- a/packages/common/__tests__/dynamicTemplate.test.ts +++ b/packages/common/__tests__/dynamicTemplate.test.ts @@ -1,6 +1,10 @@ import { readFileSync } from 'node:fs'; import * as path from 'node:path'; import { fileURLToPath } from 'node:url'; +import { + getDynamicContainerMetadata, + setDynamicContainerMetadata, +} from '../src/dynamicContainer.js'; import { getDynamicTemplate } from '../src/dynamicTemplate.js'; import { Template, Schema, Font } from '../src/index.js'; @@ -68,6 +72,16 @@ describe('getDynamicTemplate', () => { }; describe('Single page scenarios', () => { + test('should ignore non-finite dynamic container padding metadata', () => { + const schema = { ...template.schemas[0][0] }; + setDynamicContainerMetadata(schema, { + childNames: ['a'], + paddingBottom: Number.NaN, + }); + + expect(getDynamicContainerMetadata(schema)).toEqual({ childNames: ['a'] }); + }); + test('should handle no page break', async () => { const increaseHeights = [10, 10, 10, 10, 10]; const dynamicTemplate = await getDynamicTemplate( @@ -83,6 +97,69 @@ describe('getDynamicTemplate', () => { ); expect(dynamicTemplate.schemas[0][1].name).toEqual('b'); }); + + test('should resize non-flow decoration schemas without pushing their children twice', async () => { + const dynamicTemplate = await getDynamicTemplate({ + template: { + schemas: [ + [ + { + name: 'box', + content: '', + type: 'rectangle', + position: { x: 10, y: 10 }, + width: 80, + height: 20, + }, + { + name: 'a', + content: 'a', + type: 'a', + position: { x: 14, y: 14 }, + width: 20, + height: 5, + }, + { + name: 'b', + content: 'b', + type: 'b', + position: { x: 10, y: 30 }, + width: 20, + height: 5, + }, + ], + ], + basePdf: { width: 100, height: 100, padding: [0, 0, 0, 0] }, + }, + input, + options, + _cache: new Map(), + getDynamicHeights: async (_value: string, args: { schema: Schema }) => { + if (args.schema.name === 'box') { + return { heights: [40], contributesToFlow: false }; + } + if (args.schema.name === 'a') { + return [30]; + } + return [args.schema.height]; + }, + }); + + expect(dynamicTemplate.schemas[0][0]).toMatchObject({ + name: 'box', + position: { x: 10, y: 10 }, + height: 40, + }); + expect(dynamicTemplate.schemas[0][1]).toMatchObject({ + name: 'a', + position: { x: 14, y: 14 }, + height: 30, + }); + expect(dynamicTemplate.schemas[0][2]).toMatchObject({ + name: 'b', + position: { x: 10, y: 55 }, + }); + }); }); describe('Multiple page scenarios', () => { diff --git a/packages/common/src/dynamicContainer.ts b/packages/common/src/dynamicContainer.ts new file mode 100644 index 00000000..741ea117 --- /dev/null +++ b/packages/common/src/dynamicContainer.ts @@ -0,0 +1,44 @@ +import type { Schema } from './types.js'; + +export const DYNAMIC_CONTAINER_METADATA_KEY = '__pdfmeDynamicContainer' as const; + +export type DynamicContainerMetadata = { + childNames: string[]; + paddingBottom?: number; +}; + +const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null; + +export const getDynamicContainerMetadata = ( + schema: Schema, +): DynamicContainerMetadata | undefined => { + const value = (schema as Record)[DYNAMIC_CONTAINER_METADATA_KEY]; + if (!isRecord(value) || !Array.isArray(value.childNames)) return undefined; + + const childNames = value.childNames.filter( + (childName): childName is string => typeof childName === 'string' && childName.length > 0, + ); + if (childNames.length === 0) return undefined; + + const paddingBottom = + typeof value.paddingBottom === 'number' && Number.isFinite(value.paddingBottom) + ? Math.max(0, value.paddingBottom) + : undefined; + + return { childNames, paddingBottom }; +}; + +export const setDynamicContainerMetadata = (schema: Schema, metadata: DynamicContainerMetadata) => { + const childNames = metadata.childNames.filter((childName) => childName.length > 0); + if (childNames.length === 0) return; + const paddingBottom = + typeof metadata.paddingBottom === 'number' && Number.isFinite(metadata.paddingBottom) + ? Math.max(0, metadata.paddingBottom) + : undefined; + + (schema as Record)[DYNAMIC_CONTAINER_METADATA_KEY] = { + childNames, + ...(paddingBottom != null ? { paddingBottom } : {}), + }; +}; diff --git a/packages/common/src/dynamicTemplate.ts b/packages/common/src/dynamicTemplate.ts index ac8d39db..6adfd6eb 100644 --- a/packages/common/src/dynamicTemplate.ts +++ b/packages/common/src/dynamicTemplate.ts @@ -1,10 +1,10 @@ import { Schema, Template, - BasePdf, BlankPdf, CommonOptions, DynamicLayoutCallbackResult, + DynamicLayoutArgs, DynamicLayoutResult, } from './types.js'; import { cloneDeep, isBlankPdf } from './helper.js'; @@ -20,12 +20,7 @@ interface ModifyTemplateForDynamicTableArg { options: CommonOptions; getDynamicHeights: ( value: string, - args: { - schema: Schema; - basePdf: BasePdf; - options: CommonOptions; - _cache: Map; - }, + args: DynamicLayoutArgs, ) => Promise; } @@ -250,7 +245,9 @@ function processDynamicPage( // Update offset: difference between actual and original end position const originalGlobalEndY = item.baseY + item.height; - totalYOffset = actualGlobalEndY - originalGlobalEndY; + if (item.dynamicLayout.contributesToFlow !== false) { + totalYOffset = actualGlobalEndY - originalGlobalEndY; + } } sortPagesByOrder(pages, orderMap); @@ -317,6 +314,9 @@ export const getDynamicTemplate = async ( basePdf, options, _cache, + input, + schemas: template.schemas, + pageSchemas, }).then(normalizeDynamicLayoutResult); }), ); diff --git a/packages/common/src/index.ts b/packages/common/src/index.ts index 181cfba4..ae8f52ac 100644 --- a/packages/common/src/index.ts +++ b/packages/common/src/index.ts @@ -83,6 +83,12 @@ import { } from './helper.js'; import { PAGE_SIZE_PRESETS, detectPaperSize, resolvePageSize } from './pageSize.js'; import { getDynamicTemplate } from './dynamicTemplate.js'; +import { + DYNAMIC_CONTAINER_METADATA_KEY, + getDynamicContainerMetadata, + setDynamicContainerMetadata, +} from './dynamicContainer.js'; +import type { DynamicContainerMetadata } from './dynamicContainer.js'; import { createDynamicLayoutSplitRange, getDynamicLayoutSplitRange } from './splitRange.js'; import { replacePlaceholders } from './expression.js'; import { pluginRegistry } from './pluginRegistry.js'; @@ -110,6 +116,9 @@ export { getInputFromTemplate, isBlankPdf, getDynamicTemplate, + DYNAMIC_CONTAINER_METADATA_KEY, + getDynamicContainerMetadata, + setDynamicContainerMetadata, replacePlaceholders, checkFont, checkInputs, @@ -178,4 +187,5 @@ export type { PageOrientation, PageSize, PageSizePreset, + DynamicContainerMetadata, }; diff --git a/packages/common/src/types.ts b/packages/common/src/types.ts index bf978d97..b10c0f4f 100644 --- a/packages/common/src/types.ts +++ b/packages/common/src/types.ts @@ -265,6 +265,12 @@ export type DynamicLayoutPatchArgs = { export type DynamicLayoutResult = { heights: number[]; avoidFirstUnitOnly?: boolean; + /** + * When false, the schema is emitted with its patched height but its height delta + * does not push later schemas. This is used for decoration schemas whose flow is + * driven by child schemas. + */ + contributesToFlow?: boolean; patchSplitSchema?: (args: DynamicLayoutPatchArgs) => Partial; }; @@ -275,6 +281,9 @@ export type DynamicLayoutArgs = { basePdf: BasePdf; options: CommonOptions; _cache: Map; + input?: Record; + schemas?: Schema[][]; + pageSchemas?: Schema[]; }; export type GetDynamicLayout = ( diff --git a/packages/generator/__tests__/__image_snapshots__/jsx-form-fields-1.png b/packages/generator/__tests__/__image_snapshots__/jsx-form-fields-1.png index 45cd2608..80429aef 100644 Binary files a/packages/generator/__tests__/__image_snapshots__/jsx-form-fields-1.png and b/packages/generator/__tests__/__image_snapshots__/jsx-form-fields-1.png differ diff --git a/packages/generator/__tests__/generate.test.ts b/packages/generator/__tests__/generate.test.ts index 9bd652d9..dea17ff4 100644 --- a/packages/generator/__tests__/generate.test.ts +++ b/packages/generator/__tests__/generate.test.ts @@ -1,5 +1,11 @@ import generate from '../src/generate.js'; -import { Template, BLANK_PDF, Schema, type Plugin } from '@pdfme/common'; +import { + Template, + BLANK_PDF, + Schema, + setDynamicContainerMetadata, + type Plugin, +} from '@pdfme/common'; import { PDFDocument } from '@pdfme/pdf-lib'; import { getFont, getImageSnapshotOptions, pdfToImages } from './utils.js'; @@ -163,6 +169,167 @@ describe('generate integrate test', () => { expect(after?.position.y).toBeGreaterThan(20); }); + test('resizes JSX dynamic container decorations around expanded children', async () => { + const renderedSchemas: Schema[] = []; + const createProbePlugin = (defaultSchema: Schema): Plugin => ({ + pdf: ({ schema }) => { + renderedSchemas.push({ + ...schema, + position: { ...schema.position }, + }); + }, + ui: () => {}, + propPanel: { + schema: {}, + defaultSchema, + }, + }); + const boxSchema: Schema = { + name: 'box', + type: 'rectangle', + content: '', + position: { x: 10, y: 10 }, + width: 50, + height: 24, + color: '#f8fafc', + }; + setDynamicContainerMetadata(boxSchema, { + childNames: ['label', 'body'], + paddingBottom: 4, + }); + + await generate({ + template: { + basePdf: { width: 100, height: 100, padding: [10, 10, 10, 10] }, + schemas: [ + [ + boxSchema, + { + ...textObject(14, 14, 'label'), + width: 42, + height: 5, + readOnly: true, + content: 'Message', + }, + { + ...textObject(14, 21, 'body'), + width: 32, + height: 5, + overflow: 'expand', + fontSize: 10, + lineHeight: 1, + characterSpacing: 0, + }, + { + ...textObject(10, 38, 'after'), + width: 50, + height: 5, + }, + ], + ], + }, + inputs: [{ body: 'long text '.repeat(10), after: 'after' }], + options: { font: getFont() }, + plugins: { + rectangle: createProbePlugin(boxSchema), + text: createProbePlugin(textObject(0, 0)), + }, + }); + + const box = renderedSchemas.find((schema) => schema.name === 'box'); + const body = renderedSchemas.find((schema) => schema.name === 'body'); + const after = renderedSchemas.find((schema) => schema.name === 'after'); + + expect(body?.height).toBeGreaterThan(5); + expect(box?.height).toBeCloseTo(21 - 10 + (body?.height ?? 0) + 4); + expect(after?.position.y).toBeCloseTo(38 + (body?.height ?? 0) - 5); + }); + + test('propagates expanded child height through nested dynamic container decorations', async () => { + const renderedSchemas: Schema[] = []; + const createProbePlugin = (defaultSchema: Schema): Plugin => ({ + pdf: ({ schema }) => { + renderedSchemas.push({ + ...schema, + position: { ...schema.position }, + }); + }, + ui: () => {}, + propPanel: { + schema: {}, + defaultSchema, + }, + }); + const outerBox: Schema = { + name: 'outer', + type: 'rectangle', + content: '', + position: { x: 10, y: 10 }, + width: 60, + height: 24, + color: '#f8fafc', + }; + const innerBox: Schema = { + name: 'inner', + type: 'rectangle', + content: '', + position: { x: 14, y: 14 }, + width: 52, + height: 16, + color: '#ffffff', + }; + setDynamicContainerMetadata(innerBox, { + childNames: ['body'], + paddingBottom: 3, + }); + setDynamicContainerMetadata(outerBox, { + childNames: ['inner'], + paddingBottom: 4, + }); + + await generate({ + template: { + basePdf: { width: 100, height: 100, padding: [10, 10, 10, 10] }, + schemas: [ + [ + outerBox, + innerBox, + { + ...textObject(18, 18, 'body'), + width: 28, + height: 5, + overflow: 'expand', + fontSize: 10, + lineHeight: 1, + characterSpacing: 0, + }, + { + ...textObject(10, 34, 'after'), + width: 50, + height: 5, + }, + ], + ], + }, + inputs: [{ body: 'long text '.repeat(10), after: 'after' }], + options: { font: getFont() }, + plugins: { + rectangle: createProbePlugin(outerBox), + text: createProbePlugin(textObject(0, 0)), + }, + }); + + const outer = renderedSchemas.find((schema) => schema.name === 'outer'); + const inner = renderedSchemas.find((schema) => schema.name === 'inner'); + const body = renderedSchemas.find((schema) => schema.name === 'body'); + const after = renderedSchemas.find((schema) => schema.name === 'after'); + + expect(body?.height).toBeGreaterThan(5); + expect(inner?.height).toBeCloseTo(18 - 14 + (body?.height ?? 0) + 3); + expect(outer?.height).toBeCloseTo(14 - 10 + (inner?.height ?? 0) + 4); + expect(after?.position.y).toBeCloseTo(34 + (body?.height ?? 0) - 5); + }); + test('splits expanded text schemas by line across blank PDF pages', async () => { const renderedSchemas: Schema[] = []; const textProbePlugin: Plugin = { @@ -352,7 +519,7 @@ ERROR MESSAGE: Too small: expected array to have >=1 items } catch (e: any) { expect(e.message).toEqual( `[@pdfme/common] fallback flag is not found in font. true fallback flag must be only one. -Check this document: https://pdfme.com/docs/custom-fonts#about-font-type` +Check this document: https://pdfme.com/docs/custom-fonts#about-font-type`, ); } }); @@ -383,7 +550,7 @@ Check this document: https://pdfme.com/docs/custom-fonts#about-font-type` } catch (e: any) { expect(e.message).toEqual( `[@pdfme/common] 2 fallback flags found in font. true fallback flag must be only one. -Check this document: https://pdfme.com/docs/custom-fonts#about-font-type` +Check this document: https://pdfme.com/docs/custom-fonts#about-font-type`, ); } }); @@ -419,7 +586,7 @@ Check this document: https://pdfme.com/docs/custom-fonts#about-font-type` } catch (e: any) { expect(e.message).toEqual( `[@pdfme/common] DUMMY_FONT of template.schemas is not found in font. -Check this document: https://pdfme.com/docs/custom-fonts` +Check this document: https://pdfme.com/docs/custom-fonts`, ); } }); diff --git a/packages/jsx/README.md b/packages/jsx/README.md index 6612d096..b16a4b72 100644 --- a/packages/jsx/README.md +++ b/packages/jsx/README.md @@ -32,6 +32,9 @@ it provides its own `jsx-runtime` and `jsx-dev-runtime`. - `Text` and `MultiVariableText` heights are measured with pdfme's text/rich text wrapping helpers when `height` is omitted. Pass an explicit `height` when you need a fixed field box. +- Auto-height visual `Box` decorations track child `overflow="expand"` reflow for normal same-page + parent/child containers. Children can still split across pages, but the parent Box decoration is + not a multi-page container yet. - `Text textFormat="inline-markdown"` is read-only only. Editable `Text` values use plain content. - `MultiVariableText` uses `text` or children as the template string and stores `values` as the JSON input. Variable names are inferred from `{name}` placeholders and can also be passed with diff --git a/packages/jsx/src/__tests__/render.test.tsx b/packages/jsx/src/__tests__/render.test.tsx index 9da85345..3d0ba49e 100644 --- a/packages/jsx/src/__tests__/render.test.tsx +++ b/packages/jsx/src/__tests__/render.test.tsx @@ -1,6 +1,11 @@ /** @jsxImportSource @pdfme/jsx */ import { describe, expect, it } from 'vitest'; -import { isBlankPdf, PAGE_SIZE_PRESETS } from '@pdfme/common'; +import { + DYNAMIC_CONTAINER_METADATA_KEY, + getDynamicContainerMetadata, + isBlankPdf, + PAGE_SIZE_PRESETS, +} from '@pdfme/common'; import { Absolute, @@ -597,6 +602,27 @@ describe('@pdfme/jsx renderToTemplate', () => { expect(after?.position.y).toBeCloseTo(box?.height ?? 0); }); + it('marks auto-height visual Box schemas as dynamic containers', async () => { + const result = await renderToTemplate( + + + + Message + + + + , + ); + + const [box, label, message] = result.template.schemas[0] ?? []; + expect(box?.type).toBe('rectangle'); + expect(getDynamicContainerMetadata(box!)).toEqual({ + childNames: [label?.name, message?.name], + paddingBottom: 2, + }); + expect(box).toHaveProperty(DYNAMIC_CONTAINER_METADATA_KEY); + }); + it('does not render a rectangle schema for a Box without visual styles', async () => { const result = await renderToTemplate( diff --git a/packages/jsx/src/render.ts b/packages/jsx/src/render.ts index 54a609b1..b747a40a 100644 --- a/packages/jsx/src/render.ts +++ b/packages/jsx/src/render.ts @@ -1,4 +1,11 @@ -import { getDefaultFont, isBlankPdf, pt2mm, resolvePageSize } from '@pdfme/common'; +import { + getDefaultFont, + getDynamicContainerMetadata, + isBlankPdf, + pt2mm, + resolvePageSize, + setDynamicContainerMetadata, +} from '@pdfme/common'; import type { Font, Schema, Template } from '@pdfme/common'; import type { CellStyle as SchemaCellStyle, @@ -791,7 +798,20 @@ const renderBox = async ( const height = props.height ?? childSize.height + padding.top + padding.bottom; if (needsRect) { - ctx.schemas[beforeCount] = { ...ctx.schemas[beforeCount]!, height }; + const boxSchema = { ...ctx.schemas[beforeCount]!, height }; + if (props.height == null) { + const childSchemas = ctx.schemas.slice(beforeCount + 1); + const childNames = childSchemas + .map((schema) => schema.name) + .filter((name): name is string => typeof name === 'string' && name.length > 0); + if (childSchemas.some(isSchemaDynamicContainerSource)) { + setDynamicContainerMetadata(boxSchema, { + childNames, + paddingBottom: padding.bottom, + }); + } + } + ctx.schemas[beforeCount] = boxSchema; } return { width, height }; @@ -802,6 +822,13 @@ const renderSpacer = (props: SpacerProps): { width: number; height: number } => height: props.height ?? 0, }); +const isSchemaDynamicContainerSource = (schema: Schema) => + schema.type === 'table' || + schema.type === 'list' || + ((schema.type === 'text' || schema.type === 'multiVariableText') && + (schema as { overflow?: unknown }).overflow === 'expand') || + getDynamicContainerMetadata(schema) != null; + const renderText = async ( props: TextProps, frame: Rect, diff --git a/packages/schemas/src/dynamicLayout.ts b/packages/schemas/src/dynamicLayout.ts index 6b5fb8ed..42fbe1a3 100644 --- a/packages/schemas/src/dynamicLayout.ts +++ b/packages/schemas/src/dynamicLayout.ts @@ -1,4 +1,11 @@ -import type { DynamicLayoutArgs, DynamicLayoutCallbackResult, Schema } from '@pdfme/common'; +import { + getDynamicContainerMetadata, + replacePlaceholders, + type DynamicLayoutArgs, + type DynamicLayoutCallbackResult, + type DynamicLayoutResult, + type Schema, +} from '@pdfme/common'; import { getDynamicLayoutForList } from './list/dynamicTemplate.js'; import { getDynamicLayoutForMultiVariableText } from './multiVariableText/dynamicTemplate.js'; import { getDynamicLayoutForTable } from './tables/dynamicTemplate.js'; @@ -24,12 +31,88 @@ const isExpandableTextSchema = (schema: Schema) => (schema as { overflow?: unknown }).overflow === TEXT_OVERFLOW_EXPAND; export const isDynamicLayoutSchema = (schema: Schema) => - schema.type === 'table' || schema.type === 'list' || isExpandableTextSchema(schema); + schema.type === 'table' || + schema.type === 'list' || + isExpandableTextSchema(schema) || + getDynamicContainerMetadata(schema) != null; + +const normalizeDynamicLayoutResult = (result: DynamicLayoutCallbackResult): DynamicLayoutResult => { + const dynamicLayout = Array.isArray(result) ? { heights: result } : result; + return { + ...dynamicLayout, + heights: dynamicLayout.heights.length === 0 ? [0] : dynamicLayout.heights, + }; +}; + +const getSchemaValue = (schema: Schema, args: DynamicLayoutArgs): string => { + if (!schema.readOnly) { + return args.input?.[schema.name] || ''; + } + + if (schema.type !== 'text' && schema.type !== 'multiVariableText') { + return schema.content || ''; + } + + return replacePlaceholders({ + content: schema.content || '', + variables: args.input ?? {}, + schemas: args.schemas ?? [args.pageSchemas ?? []], + }); +}; + +const sumHeights = (layout: DynamicLayoutResult) => + layout.heights.reduce((total, height) => total + height, 0); + +const getDynamicLayoutForContainer = async ( + args: DynamicLayoutArgs, +): Promise => { + const metadata = getDynamicContainerMetadata(args.schema); + if (!metadata || !args.pageSchemas) return undefined; + + const pageOrder = new Map(args.pageSchemas.map((schema, index) => [schema.name, index])); + const pageSchemaMap = new Map(args.pageSchemas.map((schema) => [schema.name, schema])); + const children = metadata.childNames + .map((childName) => pageSchemaMap.get(childName)) + .filter((schema): schema is Schema => schema != null) + .sort((a, b) => { + if (a.position.y !== b.position.y) return a.position.y - b.position.y; + if (a.position.x !== b.position.x) return a.position.x - b.position.x; + return (pageOrder.get(a.name) ?? 0) - (pageOrder.get(b.name) ?? 0); + }); + if (children.length === 0) return undefined; + + let localOffset = 0; + let contentBottom = 0; + for (const child of children) { + const value = getSchemaValue(child, args); + const childLayout = normalizeDynamicLayoutResult( + await getDynamicLayoutForSchema(value, { ...args, schema: child }), + ); + const localY = Math.max(0, child.position.y - args.schema.position.y + localOffset); + const dynamicHeight = sumHeights(childLayout); + contentBottom = Math.max(contentBottom, localY + dynamicHeight); + + const originalLocalEndY = Math.max(0, child.position.y - args.schema.position.y) + child.height; + if (childLayout.contributesToFlow !== false) { + localOffset = localY + dynamicHeight - originalLocalEndY; + } + } + + const height = Math.max(args.schema.height, contentBottom + (metadata.paddingBottom ?? 0)); + return { + heights: [height], + contributesToFlow: false, + }; +}; export const getDynamicLayoutForSchema = ( value: string, args: DynamicLayoutArgs, ): Promise => { + if (getDynamicContainerMetadata(args.schema) != null) { + return getDynamicLayoutForContainer(args).then((layout) => layout ?? [args.schema.height]); + } + switch (args.schema.type) { case 'table': return getDynamicLayoutForTable(value, args); diff --git a/playground/public/template-assets/jsx-form-fields/source.tsx b/playground/public/template-assets/jsx-form-fields/source.tsx index 277dadf4..c671c13c 100644 --- a/playground/public/template-assets/jsx-form-fields/source.tsx +++ b/playground/public/template-assets/jsx-form-fields/source.tsx @@ -74,7 +74,7 @@ return ( - Editable fields usually keep explicit heights for predictable input boxes. Read-only text can usually omit height and let JSX measure it. + Auto-height Boxes follow expandable text fields in Form/Viewer and generated PDFs. Add explicit heights only when you want a fixed input area. diff --git a/playground/public/template-assets/jsx-form-fields/template.json b/playground/public/template-assets/jsx-form-fields/template.json index fcfafa27..ff8698a3 100644 --- a/playground/public/template-assets/jsx-form-fields/template.json +++ b/playground/public/template-assets/jsx-form-fields/template.json @@ -288,7 +288,14 @@ "color": "#f8fafc", "borderColor": "#cbd5e1", "borderWidth": 0.4, - "radius": 0 + "radius": 0, + "__pdfmeDynamicContainer": { + "childNames": [ + "text_7", + "message" + ], + "paddingBottom": 4 + } }, { "name": "text_7", @@ -424,7 +431,7 @@ "y": 112.509184 }, "width": 112, - "height": 17.2588832, + "height": 21.5454032, "rotate": 0, "opacity": 1, "readOnly": true, @@ -436,13 +443,13 @@ { "name": "text_9", "type": "text", - "content": "Editable fields usually keep explicit heights for predictable input boxes. Read-only text can usually omit height and let JSX measure it.", + "content": "Auto-height Boxes follow expandable text fields in Form/Viewer and generated PDFs. Add explicit heights only when you want a fixed input area.", "position": { "x": 84, "y": 116.509184 }, "width": 104, - "height": 9.2588832, + "height": 13.545403199999999, "rotate": 0, "opacity": 1, "readOnly": true, diff --git a/playground/public/template-assets/jsx-invoice/template.json b/playground/public/template-assets/jsx-invoice/template.json index e6eb629c..9a1349cf 100644 --- a/playground/public/template-assets/jsx-invoice/template.json +++ b/playground/public/template-assets/jsx-invoice/template.json @@ -321,7 +321,14 @@ "color": "", "borderColor": "#e2e8f0", "borderWidth": 0.4, - "radius": 0 + "radius": 0, + "__pdfmeDynamicContainer": { + "childNames": [ + "text_9", + "list_1" + ], + "paddingBottom": 4 + } }, { "name": "text_9", diff --git a/playground/public/template-assets/jsx-japanese-notice/template.json b/playground/public/template-assets/jsx-japanese-notice/template.json index 1a58e064..4c965c7d 100644 --- a/playground/public/template-assets/jsx-japanese-notice/template.json +++ b/playground/public/template-assets/jsx-japanese-notice/template.json @@ -103,7 +103,13 @@ "color": "#f8fafc", "borderColor": "#cbd5e1", "borderWidth": 0.4, - "radius": 0 + "radius": 0, + "__pdfmeDynamicContainer": { + "childNames": [ + "text_4" + ], + "paddingBottom": 5 + } }, { "name": "text_4", diff --git a/playground/public/template-assets/jsx-report/template.json b/playground/public/template-assets/jsx-report/template.json index 3875195f..cd42fdec 100644 --- a/playground/public/template-assets/jsx-report/template.json +++ b/playground/public/template-assets/jsx-report/template.json @@ -354,7 +354,15 @@ "color": "", "borderColor": "#cbd5e1", "borderWidth": 0.4, - "radius": 0 + "radius": 0, + "__pdfmeDynamicContainer": { + "childNames": [ + "text_12", + "text_13", + "list_1" + ], + "paddingBottom": 5 + } }, { "name": "text_12", diff --git a/website/docs/jsx.md b/website/docs/jsx.md index de0174ce..c6f8a3b1 100644 --- a/website/docs/jsx.md +++ b/website/docs/jsx.md @@ -84,7 +84,9 @@ Use `gap`, `margin`, `alignItems`, `justifyContent`, and `flex` / `flexGrow` for `Text`, `MultiVariableText`, `List`, and `Table` can usually omit `height`. JSX measures their initial content while rendering and advances the surrounding `Stack`, `Row`, or `Box`. Use explicit `height` when you want a fixed field, a fixed visual area, or predictable form input box. A `Box` without -`height` grows around its children during JSX rendering. +`height` grows around its children during JSX rendering. If that visual `Box` contains runtime +dynamic text/MVT content, pdfme also keeps the Box decoration height in sync when Form/Viewer or +generator reflows `overflow="expand"` content. ## Schema Components @@ -166,5 +168,6 @@ page coordinate system and is intended for advanced overlays such as watermarks, fonts, and generator/viewer options. - `Header`, `Footer`, and `Static` are supported only as direct children of `Document`. They generate blank `basePdf.staticSchema` and cannot be used with a custom PDF `basePdf`. -- If a form input later expands at runtime, a parent `Box` rectangle is not dynamically resized yet. - Dynamic parent/child container reflow is a future layout feature. +- Dynamic Box decoration reflow is intended for same-page parent/child visual containers. If a child + expands across a page break, the child schema can split across pages, but the parent Box decoration + is not a multi-page container yet. diff --git a/website/i18n/ja/docusaurus-plugin-content-docs/current/jsx.md b/website/i18n/ja/docusaurus-plugin-content-docs/current/jsx.md index 38286ad1..14e147a1 100644 --- a/website/i18n/ja/docusaurus-plugin-content-docs/current/jsx.md +++ b/website/i18n/ja/docusaurus-plugin-content-docs/current/jsx.md @@ -83,7 +83,8 @@ layout API は CSS/Flexbox 互換を目指していません。`gap`, `margin`, `Text`, `MultiVariableText`, `List`, `Table` は通常 `height` を省略できます。JSX render 時に初期 content を測定し、周囲の `Stack`, `Row`, `Box` を進めます。固定 field、固定 visual area、Form の入力 box を 明確にしたい場合だけ `height` を指定してください。`height` のない `Box` は、JSX render 時点では子要素の高さに -合わせて伸びます。 +合わせて伸びます。その visual `Box` の中に runtime dynamic な text / MVT がある場合、Form/Viewer や +generator が `overflow="expand"` を reflow するときも Box decoration の高さを追従させます。 ## schema component @@ -163,5 +164,6 @@ watermark、crop mark、stamp などの advanced overlay 向けです。 - 出力は `Template + inputs` です。実際の描画は通常通り pdfme plugins, fonts, generator/viewer options に依存します。 - `Header`, `Footer`, `Static` は `Document` の direct child としてのみ使えます。これらは blank `basePdf.staticSchema` を生成するため、custom PDF `basePdf` とは併用できません。 -- Form 入力によって runtime に text / MVT が expand した場合、親 `Box` の rectangle はまだ自動では - 再計算されません。親子 container の dynamic reflow は今後の layout 機能として扱います。 +- Dynamic Box decoration reflow は同一ページ内の親子 visual container 向けです。child schema が page + break を跨いで split する場合、child は複数ページに分割できますが、親 Box decoration はまだ multi-page + container にはなりません。