mirror of
https://github.com/pdfme/pdfme.git
synced 2026-06-08 06:16:48 -04:00
feat(jsx): reflow dynamic box containers
This commit is contained in:
12
PLAN.md
12
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` で比率指定を表現する。
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
44
packages/common/src/dynamicContainer.ts
Normal file
44
packages/common/src/dynamicContainer.ts
Normal file
@@ -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<string, unknown> =>
|
||||
typeof value === 'object' && value !== null;
|
||||
|
||||
export const getDynamicContainerMetadata = (
|
||||
schema: Schema,
|
||||
): DynamicContainerMetadata | undefined => {
|
||||
const value = (schema as Record<string, unknown>)[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<string, unknown>)[DYNAMIC_CONTAINER_METADATA_KEY] = {
|
||||
childNames,
|
||||
...(paddingBottom != null ? { paddingBottom } : {}),
|
||||
};
|
||||
};
|
||||
@@ -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<string | number, unknown>;
|
||||
},
|
||||
args: DynamicLayoutArgs,
|
||||
) => Promise<DynamicLayoutCallbackResult>;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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<Schema>;
|
||||
};
|
||||
|
||||
@@ -275,6 +281,9 @@ export type DynamicLayoutArgs = {
|
||||
basePdf: BasePdf;
|
||||
options: CommonOptions;
|
||||
_cache: Map<string | number, unknown>;
|
||||
input?: Record<string, string>;
|
||||
schemas?: Schema[][];
|
||||
pageSchemas?: Schema[];
|
||||
};
|
||||
|
||||
export type GetDynamicLayout = (
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 37 KiB After Width: | Height: | Size: 37 KiB |
@@ -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`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
<Page margin={0}>
|
||||
<Box background="#eeeeee" padding={{ x: 3, y: 2 }}>
|
||||
<Stack gap={1}>
|
||||
<Text>Message</Text>
|
||||
<MultiVariableText name="message" text="Hello {name}" overflow="expand" />
|
||||
</Stack>
|
||||
</Box>
|
||||
</Page>,
|
||||
);
|
||||
|
||||
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(
|
||||
<Page margin={0}>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<DynamicLayoutResult | undefined> => {
|
||||
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<DynamicLayoutCallbackResult> => {
|
||||
if (getDynamicContainerMetadata(args.schema) != null) {
|
||||
return getDynamicLayoutForContainer(args).then((layout) => layout ?? [args.schema.height]);
|
||||
}
|
||||
|
||||
switch (args.schema.type) {
|
||||
case 'table':
|
||||
return getDynamicLayoutForTable(value, args);
|
||||
|
||||
@@ -74,7 +74,7 @@ return (
|
||||
</Box>
|
||||
<Box flex={1} padding={4} background="#ecfeff" borderColor="#06b6d4" borderWidth={0.4}>
|
||||
<Text size={9} lineHeight={1.35}>
|
||||
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.
|
||||
</Text>
|
||||
</Box>
|
||||
</Row>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -103,7 +103,13 @@
|
||||
"color": "#f8fafc",
|
||||
"borderColor": "#cbd5e1",
|
||||
"borderWidth": 0.4,
|
||||
"radius": 0
|
||||
"radius": 0,
|
||||
"__pdfmeDynamicContainer": {
|
||||
"childNames": [
|
||||
"text_4"
|
||||
],
|
||||
"paddingBottom": 5
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "text_4",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 にはなりません。
|
||||
|
||||
Reference in New Issue
Block a user