@cosmicdrift/kumiko-samples 0.358.0 → 0.360.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/packages/bundled-features/package.json +9 -2
- package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-pages.ts +4 -4
- package/packages/bundled-features/src/custom-fields/changes.json +6 -0
- package/packages/bundled-features/src/custom-fields/handlers/update-tenant-field.write.ts +39 -27
- package/packages/bundled-features/src/custom-fields/schemas.ts +38 -28
- package/packages/bundled-features/src/jobs/changes.json +6 -0
- package/packages/bundled-features/src/jobs/job-run-logger.ts +23 -10
- package/packages/bundled-features/src/renderer-pdf/README.md +27 -0
- package/packages/bundled-features/src/renderer-pdf/changes.json +8 -0
- package/packages/bundled-features/src/renderer-pdf/feature.ts +48 -0
- package/packages/bundled-features/src/renderer-pdf/index.ts +9 -0
- package/packages/bundled-features/src/renderer-pdf/render-table-pdf.ts +433 -0
- package/packages/bundled-features/src/user-profile/changes.json +6 -0
- package/packages/bundled-features/src/user-profile/feature.ts +21 -11
- package/packages/bundled-features/src/user-profile/index.ts +1 -1
- package/samples/apps/use-all-bundled/feature-manifest.json +28 -2
- package/samples/apps/use-all-bundled/schema/generate.ts +5 -0
- package/samples/apps/use-all-bundled/src/run-config.ts +2 -0
- package/samples/recipes/pdf-audit-export/README.md +22 -0
- package/samples/recipes/pdf-audit-export/package.json +12 -0
- package/samples/recipes/pdf-audit-export/src/feature.ts +114 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-samples",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.360.0",
|
|
4
4
|
"description": "Source trees of the Kumiko sample recipes, sample apps and bundled features in repo layout, for tooling such as few-shot corpus builds.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.360.0",
|
|
4
4
|
"description": "Built-in features — tenant, user, auth, delivery. The stuff you'd rewrite anyway, already typed.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -116,6 +116,7 @@
|
|
|
116
116
|
"./channel-teams": "./src/channel-teams/index.ts",
|
|
117
117
|
"./channel-telegram": "./src/channel-telegram/index.ts",
|
|
118
118
|
"./renderer-simple": "./src/renderer-simple/index.ts",
|
|
119
|
+
"./renderer-pdf": "./src/renderer-pdf/index.ts",
|
|
119
120
|
"./files-provider-s3": "./src/files-provider-s3/index.ts",
|
|
120
121
|
"./rate-limiting": "./src/rate-limiting/index.ts",
|
|
121
122
|
"./presets": "./src/presets/index.ts",
|
|
@@ -166,6 +167,7 @@
|
|
|
166
167
|
"mailparser": "^3.9.32",
|
|
167
168
|
"marked": "^18.0.3",
|
|
168
169
|
"nodemailer": "^10.0.13",
|
|
170
|
+
"pdf-lib": "^1.17.1",
|
|
169
171
|
"qrcode": "^1.5.4",
|
|
170
172
|
"react": "^19.2.6",
|
|
171
173
|
"sharp": "^0.35.5",
|
|
@@ -555,6 +557,10 @@
|
|
|
555
557
|
"types": "./dist/renderer-simple/index.d.ts",
|
|
556
558
|
"default": "./dist/renderer-simple/index.js"
|
|
557
559
|
},
|
|
560
|
+
"./renderer-pdf": {
|
|
561
|
+
"types": "./dist/renderer-pdf/index.d.ts",
|
|
562
|
+
"default": "./dist/renderer-pdf/index.js"
|
|
563
|
+
},
|
|
558
564
|
"./files-provider-s3": {
|
|
559
565
|
"types": "./dist/files-provider-s3/index.d.ts",
|
|
560
566
|
"default": "./dist/files-provider-s3/index.js"
|
|
@@ -683,6 +689,7 @@
|
|
|
683
689
|
"@testing-library/user-event": "^14.6.1",
|
|
684
690
|
"@cosmicdrift/kumiko-locale-de": "workspace:*",
|
|
685
691
|
"@cosmicdrift/kumiko-locale-es": "workspace:*",
|
|
686
|
-
"jsqr": "^1.4.0"
|
|
692
|
+
"jsqr": "^1.4.0",
|
|
693
|
+
"unpdf": "^1.8.1"
|
|
687
694
|
}
|
|
688
695
|
}
|
package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-pages.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Public § 312k pages (/legal/kuendigen, /legal/cancel): form → review → confirm,
|
|
2
2
|
// no JavaScript. Every echoed value goes through escapeHtml/escapeHtmlAttr.
|
|
3
3
|
|
|
4
|
-
import type
|
|
4
|
+
import { type AnonymousExtraRoute, getSessionCookies } from "@cosmicdrift/kumiko-framework/api";
|
|
5
5
|
import { escapeHtml, escapeHtmlAttr } from "@cosmicdrift/kumiko-headless";
|
|
6
6
|
import type { Context } from "hono";
|
|
7
7
|
import { getCookie } from "hono/cookie";
|
|
@@ -279,7 +279,6 @@ type WriteResponseBody = {
|
|
|
279
279
|
const RATE_LIMITED_CODE = "rate_limited";
|
|
280
280
|
|
|
281
281
|
const TENANT_HEADER = "x-tenant";
|
|
282
|
-
const TENANT_COOKIE = "kumiko_tenant";
|
|
283
282
|
// Exactly what the anonymous tenant resolution reads. The resolver and its
|
|
284
283
|
// trust rules still decide; the re-entry only sees what a direct /api/write
|
|
285
284
|
// from this client would see. Never the session cookie or Authorization (the
|
|
@@ -298,9 +297,10 @@ function reentryHeaders(c: Context): Headers {
|
|
|
298
297
|
const value = c.req.header(name);
|
|
299
298
|
if (value !== undefined) headers.set(name, value);
|
|
300
299
|
}
|
|
301
|
-
const
|
|
300
|
+
const tenantCookieName = getSessionCookies(c).names.tenant;
|
|
301
|
+
const tenantCookie = getCookie(c, tenantCookieName);
|
|
302
302
|
if (tenantCookie !== undefined) {
|
|
303
|
-
headers.set("cookie", `${
|
|
303
|
+
headers.set("cookie", `${tenantCookieName}=${encodeURIComponent(tenantCookie)}`);
|
|
304
304
|
}
|
|
305
305
|
return headers;
|
|
306
306
|
}
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.359.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "update-tenant-field keeps the stored type when serializedField.type is omitted",
|
|
6
|
+
"detail": "custom-fields: update-tenant-field accepts serializedField without type and keeps the stored type; a different type is still refused with 422 field_type_immutable. The handler declares excludedFields [\"type\"]."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.338.0",
|
|
4
10
|
"type": "fix",
|
|
@@ -4,32 +4,33 @@ import { fieldDefinitionAggregateId } from "../aggregate-id.js";
|
|
|
4
4
|
import { DEFAULT_FIELD_DEFINITION_WRITE_ROLES } from "../constants.js";
|
|
5
5
|
import { fieldDefinitionExecutor } from "../executor.js";
|
|
6
6
|
import { buildFieldDefinitionColumns } from "../lib/field-definition-row.js";
|
|
7
|
-
import { type UpdateFieldPayload, updateFieldPayloadSchema } from "../schemas.js";
|
|
7
|
+
import { fieldTypeSchema, type UpdateFieldPayload, updateFieldPayloadSchema } from "../schemas.js";
|
|
8
8
|
|
|
9
|
-
// update-tenant-field
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
9
|
+
// update-tenant-field: a TenantAdmin replaces the state of an existing field
|
|
10
|
+
// definition (full replacement: payload shape = define except that
|
|
11
|
+
// `serializedField.type` may be omitted). Identity is (entityName, fieldKey),
|
|
12
|
+
// giving the same deterministic aggregate id as define/delete; tenantId comes
|
|
13
|
+
// from event.user, so a cross-tenant mutation is impossible (a foreign
|
|
14
|
+
// definition derives a different aggregate id and yields 404).
|
|
15
15
|
//
|
|
16
|
-
// **type
|
|
17
|
-
// host
|
|
18
|
-
//
|
|
19
|
-
// delete+redefine
|
|
20
|
-
//
|
|
16
|
+
// **type is immutable**; an omitted type keeps the stored one. A type change
|
|
17
|
+
// would orphan existing values in the host entities' customFields jsonb (a
|
|
18
|
+
// text value under a number field), so delete + re-define is the honest path
|
|
19
|
+
// (Bug-Bash D2: deliberately no delete+redefine inside update, since that
|
|
20
|
+
// destroys event history and field ids, which is exactly the break a type
|
|
21
|
+
// change needs to make visible).
|
|
21
22
|
//
|
|
22
|
-
// **
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
// re-
|
|
27
|
-
//
|
|
28
|
-
//
|
|
23
|
+
// **Known MVP limit (deliberate):** the edit does NOT reconcile existing host
|
|
24
|
+
// values against the new definition. Narrowed constraints (enum value removed,
|
|
25
|
+
// tighter min/max) leave old values silently non-conformant, required
|
|
26
|
+
// false→true leaves existing rows incomplete, and toggling searchable does not
|
|
27
|
+
// re-index. Values are validated against the current definition on the NEXT
|
|
28
|
+
// write of the host row; a reject-with-conflict-list variant is the upgrade
|
|
29
|
+
// once the need is real.
|
|
29
30
|
//
|
|
30
|
-
// **skipOptimisticLock:**
|
|
31
|
-
// (
|
|
32
|
-
//
|
|
31
|
+
// **skipOptimisticLock:** definition edits are admin-only and low-frequency
|
|
32
|
+
// (same trade-off as the quota soft cap in define), so last write wins instead
|
|
33
|
+
// of a version round trip through the edit screen.
|
|
33
34
|
export interface UpdateTenantFieldOptions {
|
|
34
35
|
/** Access roles for this handler. Default ["TenantAdmin"] (#2296) — see
|
|
35
36
|
* DefineTenantFieldOptions.roles, the definition-CRUD triad shares one gate. */
|
|
@@ -44,7 +45,8 @@ export function createUpdateTenantFieldHandler(
|
|
|
44
45
|
schema: updateFieldPayloadSchema,
|
|
45
46
|
access: { roles: opts.roles ?? DEFAULT_FIELD_DEFINITION_WRITE_ROLES },
|
|
46
47
|
description:
|
|
47
|
-
"Replaces a tenant-owned custom-field definition with a complete new spec
|
|
48
|
+
"Replaces a tenant-owned custom-field definition with a complete new spec; serializedField.type may be omitted, in which case the stored type applies, and a different type is refused. Use it to relabel or re-constrain an existing field rather than to create or remove one.",
|
|
49
|
+
excludedFields: ["type"],
|
|
48
50
|
handler: async (event, ctx) => {
|
|
49
51
|
const payload = event.payload as UpdateFieldPayload; // @cast-boundary engine-payload
|
|
50
52
|
const tenantId = event.user.tenantId;
|
|
@@ -70,12 +72,19 @@ export function createUpdateTenantFieldHandler(
|
|
|
70
72
|
return failNotFound("field-definition", aggregateId);
|
|
71
73
|
}
|
|
72
74
|
|
|
73
|
-
|
|
75
|
+
const storedType = fieldTypeSchema.safeParse(existing["type"]);
|
|
76
|
+
if (!storedType.success) {
|
|
77
|
+
throw new Error(
|
|
78
|
+
`update-tenant-field: stored definition ${aggregateId} has an invalid type`,
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
const requestedType = payload.serializedField.type;
|
|
82
|
+
if (requestedType !== undefined && requestedType !== storedType.data) {
|
|
74
83
|
return failUnprocessable("field_type_immutable", {
|
|
75
84
|
entityName: payload.entityName,
|
|
76
85
|
fieldKey: payload.fieldKey,
|
|
77
|
-
currentType:
|
|
78
|
-
requestedType
|
|
86
|
+
currentType: storedType.data,
|
|
87
|
+
requestedType,
|
|
79
88
|
});
|
|
80
89
|
}
|
|
81
90
|
|
|
@@ -84,7 +93,10 @@ export function createUpdateTenantFieldHandler(
|
|
|
84
93
|
entityName: _entityName,
|
|
85
94
|
fieldKey: _fieldKey,
|
|
86
95
|
...changes
|
|
87
|
-
} = buildFieldDefinitionColumns(
|
|
96
|
+
} = buildFieldDefinitionColumns({
|
|
97
|
+
...payload,
|
|
98
|
+
serializedField: { ...payload.serializedField, type: storedType.data },
|
|
99
|
+
});
|
|
88
100
|
|
|
89
101
|
return fieldDefinitionExecutor.update({ id: aggregateId, changes }, event.user, ctx.db, {
|
|
90
102
|
skipOptimisticLock: true,
|
|
@@ -2,7 +2,7 @@ import * as z from "zod";
|
|
|
2
2
|
import { SUPPORTED_FIELD_TYPES } from "./constants.js";
|
|
3
3
|
|
|
4
4
|
// Field-Type-Validator — pinnt valid type-Werte für fieldDefinition.
|
|
5
|
-
const fieldTypeSchema = z.enum(SUPPORTED_FIELD_TYPES);
|
|
5
|
+
export const fieldTypeSchema = z.enum(SUPPORTED_FIELD_TYPES);
|
|
6
6
|
|
|
7
7
|
// Per-field access-control. When set, `set/clear-custom-field` handlers
|
|
8
8
|
// require the calling user to hold at least one of the listed roles —
|
|
@@ -37,29 +37,37 @@ export type CustomFieldAccess = z.infer<typeof customFieldAccessSchema>;
|
|
|
37
37
|
// Everything else stays loose pending B2's per-type discriminated-union —
|
|
38
38
|
// EXCEPT `sensitive`, which is explicitly rejected below: custom fields
|
|
39
39
|
// carry supplemental business data only, PII support was removed (#972).
|
|
40
|
+
const serializedFieldBaseShape = {
|
|
41
|
+
fieldAccess: customFieldAccessSchema,
|
|
42
|
+
// `retention` (T1.5d): per-field expiry. The retention-cron strips
|
|
43
|
+
// values whose host-row `modified_at` is older than `keepFor`. Strategy
|
|
44
|
+
// `delete` removes the key from the customFields jsonb; `anonymize`
|
|
45
|
+
// sets it to `null` (key stays, value gone — preserves the schema
|
|
46
|
+
// shape for downstream consumers).
|
|
47
|
+
retention: z
|
|
48
|
+
.object({
|
|
49
|
+
keepFor: z
|
|
50
|
+
.string()
|
|
51
|
+
.regex(/^\d+[hdwmy]$/, "keepFor must match /^\\d+[hdwmy]$/ (e.g. '30d', '10y')"),
|
|
52
|
+
strategy: z.enum(["delete", "anonymize"]),
|
|
53
|
+
})
|
|
54
|
+
.optional(),
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const rejectSensitive = [
|
|
58
|
+
(v: Record<string, unknown>) => !("sensitive" in v),
|
|
59
|
+
"custom fields don't support PII — `sensitive` was removed (#972). Model personal data as a schema entity field with a pii/userOwned/tenantOwned annotation instead.",
|
|
60
|
+
] as const;
|
|
61
|
+
|
|
40
62
|
const serializedFieldSchema = z
|
|
41
|
-
.looseObject({
|
|
42
|
-
type: fieldTypeSchema,
|
|
43
|
-
fieldAccess: customFieldAccessSchema,
|
|
44
|
-
// `retention` (T1.5d): per-field expiry. The retention-cron strips
|
|
45
|
-
// values whose host-row `modified_at` is older than `keepFor`. Strategy
|
|
46
|
-
// `delete` removes the key from the customFields jsonb; `anonymize`
|
|
47
|
-
// sets it to `null` (key stays, value gone — preserves the schema
|
|
48
|
-
// shape for downstream consumers).
|
|
49
|
-
retention: z
|
|
50
|
-
.object({
|
|
51
|
-
keepFor: z
|
|
52
|
-
.string()
|
|
53
|
-
.regex(/^\d+[hdwmy]$/, "keepFor must match /^\\d+[hdwmy]$/ (e.g. '30d', '10y')"),
|
|
54
|
-
strategy: z.enum(["delete", "anonymize"]),
|
|
55
|
-
})
|
|
56
|
-
.optional(),
|
|
57
|
-
})
|
|
63
|
+
.looseObject({ type: fieldTypeSchema, ...serializedFieldBaseShape })
|
|
58
64
|
.refine((v) => typeof v["type"] === "string", "serializedField must have a string `type`")
|
|
59
|
-
.refine(
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
65
|
+
.refine(...rejectSensitive);
|
|
66
|
+
|
|
67
|
+
// Update may omit `type`: it is immutable, so the handler takes it from the stored definition.
|
|
68
|
+
const updateSerializedFieldSchema = z
|
|
69
|
+
.looseObject({ type: fieldTypeSchema.optional(), ...serializedFieldBaseShape })
|
|
70
|
+
.refine(...rejectSensitive);
|
|
63
71
|
|
|
64
72
|
// i18n-labels — `{ de: "...", en: "...", ... }`. Mindestens ein Eintrag.
|
|
65
73
|
const labelSchema = z.record(z.string().min(2).max(8), z.string().min(1));
|
|
@@ -83,12 +91,14 @@ export const defineFieldPayloadSchema = z.object({
|
|
|
83
91
|
});
|
|
84
92
|
export type DefineFieldPayload = z.infer<typeof defineFieldPayloadSchema>;
|
|
85
93
|
|
|
86
|
-
// Payload
|
|
87
|
-
//
|
|
88
|
-
//
|
|
89
|
-
//
|
|
90
|
-
export const updateFieldPayloadSchema = defineFieldPayloadSchema
|
|
91
|
-
|
|
94
|
+
// Payload for `update-tenant-field`: full replacement like define (the edit
|
|
95
|
+
// screen sends the complete new state; entityName+fieldKey are the identity),
|
|
96
|
+
// except that `serializedField.type` may be omitted. type is immutable; a
|
|
97
|
+
// differing value is refused in the handler.
|
|
98
|
+
export const updateFieldPayloadSchema = defineFieldPayloadSchema.extend({
|
|
99
|
+
serializedField: updateSerializedFieldSchema,
|
|
100
|
+
});
|
|
101
|
+
export type UpdateFieldPayload = z.infer<typeof updateFieldPayloadSchema>;
|
|
92
102
|
|
|
93
103
|
// Payload für `delete-tenant-field` / `delete-system-field`.
|
|
94
104
|
export const deleteFieldPayloadSchema = z.object({
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.360.0",
|
|
4
|
+
"type": "fix",
|
|
5
|
+
"title": "Finished tenant job run replaces the older one atomically",
|
|
6
|
+
"detail": "recordTenantRunOutcome wrote the new completed or failed row and pruned older finished rows of the same tenant, job and subject in two separate statements, so the store briefly held two finished rows per subject, and two runs of one subject finishing together could each miss the other's row and leave both. Both writes now run in one transaction under a per-key advisory lock (namespace tjrl). jobs:query:tenant-runs already showed only the newest finished row, so its result does not change."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.354.0",
|
|
4
10
|
"type": "breaking",
|
|
@@ -167,6 +167,8 @@ async function encryptStartedPayload(
|
|
|
167
167
|
// pg_advisory_xact_lock namespace (int4): 'tjfl' as ASCII, disjoint from the
|
|
168
168
|
// framework's other fixed advisory-lock keys.
|
|
169
169
|
const TENANT_JOB_FAILURE_LOCK_NAMESPACE = 0x746a666c;
|
|
170
|
+
// 'tjrl': serialises the finished-row write + prune per tenant run key.
|
|
171
|
+
const TENANT_JOB_RUN_LOCK_NAMESPACE = 0x746a726c;
|
|
170
172
|
|
|
171
173
|
// fw#3079 — which row the tenant-visible failure record lives in: one per
|
|
172
174
|
// (tenant, job, subject), so the next outcome of the same work replaces or
|
|
@@ -311,16 +313,27 @@ async function recordTenantRunOutcome(
|
|
|
311
313
|
// skip: retry pending, the row stays active until the final attempt
|
|
312
314
|
return;
|
|
313
315
|
}
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
316
|
+
// One transaction: a reader never sees the new finished row next to the
|
|
317
|
+
// one it replaces. The per-key lock keeps two runs of the same subject that
|
|
318
|
+
// finish together from each missing the other's uncommitted row and leaving
|
|
319
|
+
// both behind.
|
|
320
|
+
await transaction(db, async (tx) => {
|
|
321
|
+
await acquireNamespacedAdvisoryLock(
|
|
322
|
+
tx,
|
|
323
|
+
TENANT_JOB_RUN_LOCK_NAMESPACE,
|
|
324
|
+
JSON.stringify([key.tenantId, key.jobName, key.subject]),
|
|
325
|
+
);
|
|
326
|
+
await upsertOnConflict(
|
|
327
|
+
tx,
|
|
328
|
+
tenantJobRunsTable,
|
|
329
|
+
{ ...key, bullJobId, status: result, finishedAt: now, updatedAt: now },
|
|
330
|
+
{ conflictKeys: ["bullJobId"], update: { status: result, finishedAt: now, updatedAt: now } },
|
|
331
|
+
);
|
|
332
|
+
await deleteMany(tx, tenantJobRunsTable, {
|
|
333
|
+
...key,
|
|
334
|
+
status: { in: ["completed", "failed"] },
|
|
335
|
+
bullJobId: { ne: bullJobId },
|
|
336
|
+
});
|
|
324
337
|
});
|
|
325
338
|
}
|
|
326
339
|
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# renderer-pdf
|
|
2
|
+
|
|
3
|
+
Public `document-pdf` renderer plugin: draws a table document as PDF in pure JavaScript (`pdf-lib`, standard Helvetica fonts), no browser and no Puppeteer service. Same input gives byte-identical output.
|
|
4
|
+
|
|
5
|
+
## Pure function
|
|
6
|
+
|
|
7
|
+
```typescript
|
|
8
|
+
import { renderTablePdf } from "@cosmicdrift/kumiko-bundled-features/renderer-pdf";
|
|
9
|
+
|
|
10
|
+
const { pdfBytes, pageCount } = await renderTablePdf(
|
|
11
|
+
{
|
|
12
|
+
title: "Prüfprotokoll",
|
|
13
|
+
meta: [{ label: "Anlage", value: "Kessel 3" }],
|
|
14
|
+
columns: [{ header: "Datum" }, { header: "Prüfer", weight: 2 }, { header: "Kosten", align: "right" }],
|
|
15
|
+
rows: [["03.02.2026", "Müller", "120,00 €"]],
|
|
16
|
+
footerText: "Kumiko GmbH",
|
|
17
|
+
pageLabel: "Seite {page} von {pages}",
|
|
18
|
+
},
|
|
19
|
+
{ format: "A4" },
|
|
20
|
+
);
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
The caller passes ready-formatted strings (dates, numbers); the renderer never reads the clock. Characters Helvetica (WinAnsi) cannot encode are drawn as `?`; umlauts, `ß` and `€` are kept.
|
|
24
|
+
|
|
25
|
+
## Plugin
|
|
26
|
+
|
|
27
|
+
Mount `createRendererPdfFeature()` next to `renderer-foundation`. It serves `kind: "document-pdf"` with a `PdfTableDocument` in `payload.variables`; `options.format` and `options.marginMm` apply. Invalid variables raise `RendererError("invalid_payload")`. A Puppeteer plugin keeps precedence (`DEFAULT_PLUGIN_BY_KIND`) when mounted.
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.360.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "Pure-JS PDF renderer for table documents",
|
|
6
|
+
"detail": "renderTablePdf(doc, options?) renders a PdfTableDocument (title, subtitle, meta lines, weighted columns, wrapped cells, header row repeated per page, footerText and a pageLabel template with {page} and {pages}) to PDF bytes with pdf-lib. Characters outside WinAnsi are replaced by \"?\". Identical input yields identical bytes. createRendererPdfFeature() registers it as plugin \"pdf\" for kind \"document-pdf\"; payload.variables is validated against pdfTableDocumentSchema, anything else fails with RendererError invalid_payload."
|
|
7
|
+
}
|
|
8
|
+
]
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { defineFeature, type FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
+
import {
|
|
3
|
+
RENDERER_EXTENSION,
|
|
4
|
+
RendererError,
|
|
5
|
+
type RenderRequest,
|
|
6
|
+
type RenderResponse,
|
|
7
|
+
} from "../renderer-foundation/index.js";
|
|
8
|
+
import { pdfTableDocumentSchema, renderTablePdf } from "./render-table-pdf.js";
|
|
9
|
+
|
|
10
|
+
export async function renderTablePdfRequest(req: RenderRequest): Promise<RenderResponse> {
|
|
11
|
+
if (req.kind !== "document-pdf") {
|
|
12
|
+
throw new RendererError(
|
|
13
|
+
`renderer-pdf supports only kind="document-pdf", got "${req.kind}"`,
|
|
14
|
+
"invalid_payload",
|
|
15
|
+
);
|
|
16
|
+
}
|
|
17
|
+
const parsed = pdfTableDocumentSchema.safeParse(req.payload.variables);
|
|
18
|
+
if (!parsed.success) {
|
|
19
|
+
throw new RendererError(
|
|
20
|
+
`renderer-pdf: payload.variables is not a table document: ${parsed.error.message}`,
|
|
21
|
+
"invalid_payload",
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
const { pdfBytes, pageCount } = await renderTablePdf(parsed.data, {
|
|
25
|
+
...(req.options?.format && { format: req.options.format }),
|
|
26
|
+
...(req.options?.marginMm && { marginMm: req.options.marginMm }),
|
|
27
|
+
});
|
|
28
|
+
return { kind: "document-pdf", pdfBytes, pageCount, sizeBytes: pdfBytes.byteLength };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function createRendererPdfFeature(): FeatureDefinition {
|
|
32
|
+
return defineFeature("renderer-pdf", (r) => {
|
|
33
|
+
r.describe(
|
|
34
|
+
'Renderer plugin for `kind="document-pdf"` that draws a table document (title, meta lines, weighted columns, repeating header, page footer) as a PDF in pure JavaScript, without a browser or Puppeteer service. `payload.variables` must be a `PdfTableDocument`. Requires `renderer-foundation`; mount a Puppeteer-based plugin instead when you need HTML/CSS layouts.',
|
|
35
|
+
);
|
|
36
|
+
r.uiHints({
|
|
37
|
+
displayLabel: "Renderer · PDF tables",
|
|
38
|
+
category: "notifications",
|
|
39
|
+
recommended: false,
|
|
40
|
+
});
|
|
41
|
+
r.requires("renderer-foundation");
|
|
42
|
+
|
|
43
|
+
r.useExtension(RENDERER_EXTENSION, "pdf", {
|
|
44
|
+
kinds: ["document-pdf"] as const,
|
|
45
|
+
render: (req) => renderTablePdfRequest(req),
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
}
|
|
@@ -0,0 +1,433 @@
|
|
|
1
|
+
import { PDFDocument, type PDFFont, type PDFPage, rgb, StandardFonts } from "pdf-lib";
|
|
2
|
+
import * as z from "zod";
|
|
3
|
+
import { RendererError } from "../renderer-foundation/index.js";
|
|
4
|
+
|
|
5
|
+
export const pdfTableDocumentSchema = z.object({
|
|
6
|
+
title: z.string(),
|
|
7
|
+
subtitle: z.string().optional(),
|
|
8
|
+
meta: z.array(z.object({ label: z.string(), value: z.string() })).optional(),
|
|
9
|
+
columns: z
|
|
10
|
+
.array(
|
|
11
|
+
z.object({
|
|
12
|
+
header: z.string(),
|
|
13
|
+
weight: z.number().positive().optional(),
|
|
14
|
+
align: z.enum(["left", "right"]).optional(),
|
|
15
|
+
}),
|
|
16
|
+
)
|
|
17
|
+
.min(1),
|
|
18
|
+
rows: z.array(z.array(z.string())),
|
|
19
|
+
emptyText: z.string().optional(),
|
|
20
|
+
footerText: z.string().optional(),
|
|
21
|
+
pageLabel: z.string().optional(),
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
export type PdfTableColumn = {
|
|
25
|
+
readonly header: string;
|
|
26
|
+
readonly weight?: number;
|
|
27
|
+
readonly align?: "left" | "right";
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export type PdfTableDocument = {
|
|
31
|
+
readonly title: string;
|
|
32
|
+
readonly subtitle?: string;
|
|
33
|
+
readonly meta?: readonly { readonly label: string; readonly value: string }[];
|
|
34
|
+
readonly columns: readonly PdfTableColumn[];
|
|
35
|
+
readonly rows: readonly (readonly string[])[];
|
|
36
|
+
readonly emptyText?: string;
|
|
37
|
+
readonly footerText?: string;
|
|
38
|
+
/** Template with `{page}` and `{pages}`; default `"{page} / {pages}"`. */
|
|
39
|
+
readonly pageLabel?: string;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export type PdfTableOptions = {
|
|
43
|
+
readonly format?: "A4" | "Letter";
|
|
44
|
+
readonly marginMm?: {
|
|
45
|
+
readonly top?: number;
|
|
46
|
+
readonly right?: number;
|
|
47
|
+
readonly bottom?: number;
|
|
48
|
+
readonly left?: number;
|
|
49
|
+
};
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
export type PdfTableResult = { readonly pdfBytes: Uint8Array; readonly pageCount: number };
|
|
53
|
+
|
|
54
|
+
const POINTS_PER_MM = 72 / 25.4;
|
|
55
|
+
const PAGE_SIZE_POINTS = {
|
|
56
|
+
A4: { width: 595.28, height: 841.89 },
|
|
57
|
+
Letter: { width: 612, height: 792 },
|
|
58
|
+
} as const;
|
|
59
|
+
const DEFAULT_MARGIN_MM = { top: 18, right: 15, bottom: 18, left: 15 } as const;
|
|
60
|
+
|
|
61
|
+
const TITLE_SIZE = 16;
|
|
62
|
+
const SUBTITLE_SIZE = 11;
|
|
63
|
+
const BODY_SIZE = 9;
|
|
64
|
+
const FOOTER_SIZE = 8;
|
|
65
|
+
const LINE_HEIGHT_FACTOR = 1.3;
|
|
66
|
+
const CELL_PADDING_X = 4;
|
|
67
|
+
const CELL_PADDING_Y = 3;
|
|
68
|
+
const FOOTER_RESERVE = 16;
|
|
69
|
+
const META_LABEL_GAP = 10;
|
|
70
|
+
const BLOCK_GAP = 10;
|
|
71
|
+
const REPLACEMENT_CHAR = "?";
|
|
72
|
+
const ELLIPSIS = "...";
|
|
73
|
+
|
|
74
|
+
const TEXT_COLOR = rgb(0.1, 0.1, 0.1);
|
|
75
|
+
const MUTED_COLOR = rgb(0.4, 0.4, 0.4);
|
|
76
|
+
const HEADER_FILL = rgb(0.92, 0.93, 0.95);
|
|
77
|
+
const RULE_COLOR = rgb(0.8, 0.8, 0.82);
|
|
78
|
+
|
|
79
|
+
type Fonts = { readonly regular: PDFFont; readonly bold: PDFFont };
|
|
80
|
+
|
|
81
|
+
function lineHeightFor(size: number): number {
|
|
82
|
+
return size * LINE_HEIGHT_FACTOR;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function createEncodableText(font: PDFFont): (text: string) => string {
|
|
86
|
+
const supported = new Set(font.getCharacterSet());
|
|
87
|
+
return (text) => {
|
|
88
|
+
let result = "";
|
|
89
|
+
for (const char of text.replace(/\t/g, " ")) {
|
|
90
|
+
const codePoint = char.codePointAt(0) ?? 0;
|
|
91
|
+
result += codePoint === 0x0a || supported.has(codePoint) ? char : REPLACEMENT_CHAR;
|
|
92
|
+
}
|
|
93
|
+
return result;
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function breakLongWord(word: string, font: PDFFont, size: number, maxWidth: number): string[] {
|
|
98
|
+
const pieces: string[] = [];
|
|
99
|
+
let current = "";
|
|
100
|
+
for (const char of word) {
|
|
101
|
+
if (current !== "" && font.widthOfTextAtSize(current + char, size) > maxWidth) {
|
|
102
|
+
pieces.push(current);
|
|
103
|
+
current = char;
|
|
104
|
+
} else {
|
|
105
|
+
current += char;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
if (current !== "") pieces.push(current);
|
|
109
|
+
return pieces;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function wrapText(text: string, font: PDFFont, size: number, maxWidth: number): string[] {
|
|
113
|
+
const lines: string[] = [];
|
|
114
|
+
for (const paragraph of text.split(/\r\n|\n|\r/)) {
|
|
115
|
+
let current = "";
|
|
116
|
+
for (const word of paragraph.split(" ").filter((w) => w !== "")) {
|
|
117
|
+
const candidate = current === "" ? word : `${current} ${word}`;
|
|
118
|
+
if (font.widthOfTextAtSize(candidate, size) <= maxWidth) {
|
|
119
|
+
current = candidate;
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
if (current !== "") lines.push(current);
|
|
123
|
+
const pieces =
|
|
124
|
+
font.widthOfTextAtSize(word, size) <= maxWidth
|
|
125
|
+
? [word]
|
|
126
|
+
: breakLongWord(word, font, size, maxWidth);
|
|
127
|
+
lines.push(...pieces.slice(0, -1));
|
|
128
|
+
current = pieces[pieces.length - 1] ?? "";
|
|
129
|
+
}
|
|
130
|
+
lines.push(current);
|
|
131
|
+
}
|
|
132
|
+
return lines;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function fitSingleLine(text: string, font: PDFFont, size: number, maxWidth: number): string {
|
|
136
|
+
if (font.widthOfTextAtSize(text, size) <= maxWidth) return text;
|
|
137
|
+
let fitted = text;
|
|
138
|
+
while (fitted !== "" && font.widthOfTextAtSize(fitted + ELLIPSIS, size) > maxWidth) {
|
|
139
|
+
fitted = fitted.slice(0, -1);
|
|
140
|
+
}
|
|
141
|
+
return fitted + ELLIPSIS;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function resolveColumnWidths(columns: readonly PdfTableColumn[], totalWidth: number): number[] {
|
|
145
|
+
const totalWeight = columns.reduce((sum, column) => sum + (column.weight ?? 1), 0);
|
|
146
|
+
return columns.map((column) => (totalWidth * (column.weight ?? 1)) / totalWeight);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function assertRowShape(doc: PdfTableDocument): void {
|
|
150
|
+
doc.rows.forEach((row, index) => {
|
|
151
|
+
if (row.length !== doc.columns.length) {
|
|
152
|
+
throw new RendererError(
|
|
153
|
+
`row ${index} has ${row.length} cells but the table has ${doc.columns.length} columns`,
|
|
154
|
+
"invalid_payload",
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export async function renderTablePdf(
|
|
161
|
+
doc: PdfTableDocument,
|
|
162
|
+
options?: PdfTableOptions,
|
|
163
|
+
): Promise<PdfTableResult> {
|
|
164
|
+
if (doc.columns.length === 0) {
|
|
165
|
+
throw new RendererError("table needs at least one column", "invalid_payload");
|
|
166
|
+
}
|
|
167
|
+
assertRowShape(doc);
|
|
168
|
+
|
|
169
|
+
const pdf = await PDFDocument.create({ updateMetadata: false });
|
|
170
|
+
const fonts: Fonts = {
|
|
171
|
+
regular: await pdf.embedFont(StandardFonts.Helvetica),
|
|
172
|
+
bold: await pdf.embedFont(StandardFonts.HelveticaBold),
|
|
173
|
+
};
|
|
174
|
+
const clean = createEncodableText(fonts.regular);
|
|
175
|
+
|
|
176
|
+
const pageSize = PAGE_SIZE_POINTS[options?.format ?? "A4"];
|
|
177
|
+
const margin = {
|
|
178
|
+
top: (options?.marginMm?.top ?? DEFAULT_MARGIN_MM.top) * POINTS_PER_MM,
|
|
179
|
+
right: (options?.marginMm?.right ?? DEFAULT_MARGIN_MM.right) * POINTS_PER_MM,
|
|
180
|
+
bottom: (options?.marginMm?.bottom ?? DEFAULT_MARGIN_MM.bottom) * POINTS_PER_MM,
|
|
181
|
+
left: (options?.marginMm?.left ?? DEFAULT_MARGIN_MM.left) * POINTS_PER_MM,
|
|
182
|
+
};
|
|
183
|
+
const contentWidth = pageSize.width - margin.left - margin.right;
|
|
184
|
+
const contentBottom = margin.bottom + FOOTER_RESERVE;
|
|
185
|
+
const columnWidths = resolveColumnWidths(doc.columns, contentWidth);
|
|
186
|
+
const bodyLineHeight = lineHeightFor(BODY_SIZE);
|
|
187
|
+
|
|
188
|
+
const rowSpaceOnEmptyPage = pageSize.height - margin.top - contentBottom;
|
|
189
|
+
if (rowSpaceOnEmptyPage < 4 * bodyLineHeight) {
|
|
190
|
+
throw new RendererError("margins leave no room for table rows", "invalid_payload");
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const headerLines = doc.columns.map((column, index) =>
|
|
194
|
+
wrapText(
|
|
195
|
+
clean(column.header),
|
|
196
|
+
fonts.bold,
|
|
197
|
+
BODY_SIZE,
|
|
198
|
+
(columnWidths[index] ?? contentWidth) - 2 * CELL_PADDING_X,
|
|
199
|
+
),
|
|
200
|
+
);
|
|
201
|
+
const headerHeight =
|
|
202
|
+
Math.max(...headerLines.map((lines) => lines.length)) * bodyLineHeight + 2 * CELL_PADDING_Y;
|
|
203
|
+
|
|
204
|
+
let page: PDFPage = pdf.addPage([pageSize.width, pageSize.height]);
|
|
205
|
+
let cursorY = pageSize.height - margin.top;
|
|
206
|
+
|
|
207
|
+
function drawLines(
|
|
208
|
+
lines: readonly string[],
|
|
209
|
+
font: PDFFont,
|
|
210
|
+
size: number,
|
|
211
|
+
left: number,
|
|
212
|
+
width: number,
|
|
213
|
+
topY: number,
|
|
214
|
+
align: "left" | "right",
|
|
215
|
+
color = TEXT_COLOR,
|
|
216
|
+
): void {
|
|
217
|
+
lines.forEach((line, index) => {
|
|
218
|
+
const lineWidth = font.widthOfTextAtSize(line, size);
|
|
219
|
+
const x = align === "right" ? left + width - lineWidth : left;
|
|
220
|
+
page.drawText(line, {
|
|
221
|
+
x,
|
|
222
|
+
y: topY - size - index * lineHeightFor(size),
|
|
223
|
+
size,
|
|
224
|
+
font,
|
|
225
|
+
color,
|
|
226
|
+
});
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function drawIntroBlock(): void {
|
|
231
|
+
const titleLines = wrapText(clean(doc.title), fonts.bold, TITLE_SIZE, contentWidth);
|
|
232
|
+
drawLines(titleLines, fonts.bold, TITLE_SIZE, margin.left, contentWidth, cursorY, "left");
|
|
233
|
+
cursorY -= titleLines.length * lineHeightFor(TITLE_SIZE);
|
|
234
|
+
|
|
235
|
+
if (doc.subtitle !== undefined) {
|
|
236
|
+
const subtitleLines = wrapText(
|
|
237
|
+
clean(doc.subtitle),
|
|
238
|
+
fonts.regular,
|
|
239
|
+
SUBTITLE_SIZE,
|
|
240
|
+
contentWidth,
|
|
241
|
+
);
|
|
242
|
+
drawLines(
|
|
243
|
+
subtitleLines,
|
|
244
|
+
fonts.regular,
|
|
245
|
+
SUBTITLE_SIZE,
|
|
246
|
+
margin.left,
|
|
247
|
+
contentWidth,
|
|
248
|
+
cursorY,
|
|
249
|
+
"left",
|
|
250
|
+
MUTED_COLOR,
|
|
251
|
+
);
|
|
252
|
+
cursorY -= subtitleLines.length * lineHeightFor(SUBTITLE_SIZE);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const meta = doc.meta ?? [];
|
|
256
|
+
if (meta.length > 0) {
|
|
257
|
+
cursorY -= BLOCK_GAP / 2;
|
|
258
|
+
const labels = meta.map((entry) => clean(entry.label));
|
|
259
|
+
const labelWidth =
|
|
260
|
+
Math.max(...labels.map((label) => fonts.bold.widthOfTextAtSize(label, BODY_SIZE))) +
|
|
261
|
+
META_LABEL_GAP;
|
|
262
|
+
meta.forEach((entry, index) => {
|
|
263
|
+
const valueLines = wrapText(
|
|
264
|
+
clean(entry.value),
|
|
265
|
+
fonts.regular,
|
|
266
|
+
BODY_SIZE,
|
|
267
|
+
contentWidth - labelWidth,
|
|
268
|
+
);
|
|
269
|
+
drawLines(
|
|
270
|
+
[labels[index] ?? ""],
|
|
271
|
+
fonts.bold,
|
|
272
|
+
BODY_SIZE,
|
|
273
|
+
margin.left,
|
|
274
|
+
labelWidth,
|
|
275
|
+
cursorY,
|
|
276
|
+
"left",
|
|
277
|
+
);
|
|
278
|
+
drawLines(
|
|
279
|
+
valueLines,
|
|
280
|
+
fonts.regular,
|
|
281
|
+
BODY_SIZE,
|
|
282
|
+
margin.left + labelWidth,
|
|
283
|
+
contentWidth - labelWidth,
|
|
284
|
+
cursorY,
|
|
285
|
+
"left",
|
|
286
|
+
);
|
|
287
|
+
cursorY -= valueLines.length * bodyLineHeight;
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
cursorY -= BLOCK_GAP;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function drawTableHeader(): void {
|
|
294
|
+
page.drawRectangle({
|
|
295
|
+
x: margin.left,
|
|
296
|
+
y: cursorY - headerHeight,
|
|
297
|
+
width: contentWidth,
|
|
298
|
+
height: headerHeight,
|
|
299
|
+
color: HEADER_FILL,
|
|
300
|
+
});
|
|
301
|
+
let x = margin.left;
|
|
302
|
+
doc.columns.forEach((column, index) => {
|
|
303
|
+
const width = columnWidths[index] ?? 0;
|
|
304
|
+
drawLines(
|
|
305
|
+
headerLines[index] ?? [],
|
|
306
|
+
fonts.bold,
|
|
307
|
+
BODY_SIZE,
|
|
308
|
+
x + CELL_PADDING_X,
|
|
309
|
+
width - 2 * CELL_PADDING_X,
|
|
310
|
+
cursorY - CELL_PADDING_Y,
|
|
311
|
+
column.align ?? "left",
|
|
312
|
+
);
|
|
313
|
+
x += width;
|
|
314
|
+
});
|
|
315
|
+
cursorY -= headerHeight;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function startContinuationPage(): void {
|
|
319
|
+
page = pdf.addPage([pageSize.width, pageSize.height]);
|
|
320
|
+
cursorY = pageSize.height - margin.top;
|
|
321
|
+
drawTableHeader();
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function drawRowSlice(
|
|
325
|
+
cellLines: readonly (readonly string[])[],
|
|
326
|
+
from: number,
|
|
327
|
+
count: number,
|
|
328
|
+
): void {
|
|
329
|
+
const height = count * bodyLineHeight + 2 * CELL_PADDING_Y;
|
|
330
|
+
let x = margin.left;
|
|
331
|
+
doc.columns.forEach((column, index) => {
|
|
332
|
+
const width = columnWidths[index] ?? 0;
|
|
333
|
+
drawLines(
|
|
334
|
+
(cellLines[index] ?? []).slice(from, from + count),
|
|
335
|
+
fonts.regular,
|
|
336
|
+
BODY_SIZE,
|
|
337
|
+
x + CELL_PADDING_X,
|
|
338
|
+
width - 2 * CELL_PADDING_X,
|
|
339
|
+
cursorY - CELL_PADDING_Y,
|
|
340
|
+
column.align ?? "left",
|
|
341
|
+
);
|
|
342
|
+
x += width;
|
|
343
|
+
});
|
|
344
|
+
page.drawLine({
|
|
345
|
+
start: { x: margin.left, y: cursorY - height },
|
|
346
|
+
end: { x: margin.left + contentWidth, y: cursorY - height },
|
|
347
|
+
thickness: 0.5,
|
|
348
|
+
color: RULE_COLOR,
|
|
349
|
+
});
|
|
350
|
+
cursorY -= height;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function drawRow(cellLines: readonly (readonly string[])[]): void {
|
|
354
|
+
const lineCount = Math.max(...cellLines.map((lines) => lines.length));
|
|
355
|
+
const rowHeight = lineCount * bodyLineHeight + 2 * CELL_PADDING_Y;
|
|
356
|
+
const emptyPageSpace = pageSize.height - margin.top - headerHeight - contentBottom;
|
|
357
|
+
|
|
358
|
+
if (rowHeight > cursorY - contentBottom && rowHeight <= emptyPageSpace) {
|
|
359
|
+
startContinuationPage();
|
|
360
|
+
}
|
|
361
|
+
let drawn = 0;
|
|
362
|
+
while (drawn < lineCount) {
|
|
363
|
+
const fitting = Math.floor((cursorY - contentBottom - 2 * CELL_PADDING_Y) / bodyLineHeight);
|
|
364
|
+
if (fitting < 1) {
|
|
365
|
+
startContinuationPage();
|
|
366
|
+
continue;
|
|
367
|
+
}
|
|
368
|
+
const count = Math.min(fitting, lineCount - drawn);
|
|
369
|
+
drawRowSlice(cellLines, drawn, count);
|
|
370
|
+
drawn += count;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
drawIntroBlock();
|
|
375
|
+
drawTableHeader();
|
|
376
|
+
|
|
377
|
+
if (doc.rows.length === 0 && doc.emptyText !== undefined) {
|
|
378
|
+
drawRow([
|
|
379
|
+
wrapText(clean(doc.emptyText), fonts.regular, BODY_SIZE, contentWidth - 2 * CELL_PADDING_X),
|
|
380
|
+
]);
|
|
381
|
+
}
|
|
382
|
+
for (const row of doc.rows) {
|
|
383
|
+
drawRow(
|
|
384
|
+
row.map((cell, index) =>
|
|
385
|
+
wrapText(
|
|
386
|
+
clean(cell),
|
|
387
|
+
fonts.regular,
|
|
388
|
+
BODY_SIZE,
|
|
389
|
+
(columnWidths[index] ?? contentWidth) - 2 * CELL_PADDING_X,
|
|
390
|
+
),
|
|
391
|
+
),
|
|
392
|
+
);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
const pages = pdf.getPages();
|
|
396
|
+
const pageLabelTemplate = doc.pageLabel ?? "{page} / {pages}";
|
|
397
|
+
const footerBaseline = margin.bottom;
|
|
398
|
+
pages.forEach((footerPage, index) => {
|
|
399
|
+
const label = clean(
|
|
400
|
+
pageLabelTemplate
|
|
401
|
+
.replaceAll("{page}", String(index + 1))
|
|
402
|
+
.replaceAll("{pages}", String(pages.length)),
|
|
403
|
+
);
|
|
404
|
+
const labelWidth = fonts.regular.widthOfTextAtSize(label, FOOTER_SIZE);
|
|
405
|
+
if (doc.footerText !== undefined) {
|
|
406
|
+
footerPage.drawText(
|
|
407
|
+
fitSingleLine(
|
|
408
|
+
clean(doc.footerText.replace(/\s+/g, " ")),
|
|
409
|
+
fonts.regular,
|
|
410
|
+
FOOTER_SIZE,
|
|
411
|
+
contentWidth - labelWidth - 2 * CELL_PADDING_X,
|
|
412
|
+
),
|
|
413
|
+
{
|
|
414
|
+
x: margin.left,
|
|
415
|
+
y: footerBaseline,
|
|
416
|
+
size: FOOTER_SIZE,
|
|
417
|
+
font: fonts.regular,
|
|
418
|
+
color: MUTED_COLOR,
|
|
419
|
+
},
|
|
420
|
+
);
|
|
421
|
+
}
|
|
422
|
+
footerPage.drawText(label, {
|
|
423
|
+
x: margin.left + contentWidth - labelWidth,
|
|
424
|
+
y: footerBaseline,
|
|
425
|
+
size: FOOTER_SIZE,
|
|
426
|
+
font: fonts.regular,
|
|
427
|
+
color: MUTED_COLOR,
|
|
428
|
+
});
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
const pdfBytes = await pdf.save();
|
|
432
|
+
return { pdfBytes, pageCount: pages.length };
|
|
433
|
+
}
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.360.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "change-email can be switched off",
|
|
6
|
+
"detail": "createUserProfileFeature({ allowEmailChange: false }) neither registers the change-email write handler nor shows the email section on the profile screen. Without the option nothing changes."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.243.1",
|
|
4
10
|
"type": "fix",
|
|
@@ -12,13 +12,21 @@ import {
|
|
|
12
12
|
} from "./constants.js";
|
|
13
13
|
import { changeEmailWrite } from "./handlers/change-email.write.js";
|
|
14
14
|
|
|
15
|
-
export
|
|
15
|
+
export type UserProfileFeatureOptions = {
|
|
16
|
+
/** Default true. false drops the change-email handler and the email
|
|
17
|
+
* section — for apps whose login email is managed elsewhere (SSO, admin-set). */
|
|
18
|
+
readonly allowEmailChange?: boolean;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export function createUserProfileFeature(options?: UserProfileFeatureOptions): FeatureDefinition {
|
|
22
|
+
const allowEmailChange = options?.allowEmailChange ?? true;
|
|
16
23
|
return defineFeature("user-profile", (r) => {
|
|
17
24
|
r.describe(
|
|
18
25
|
"Self-service account page (fw#2312: declarative `projectionDetail` bound to " +
|
|
19
26
|
"the signed-in user's own `me` row) plus its `change-email` write handler " +
|
|
20
27
|
"(re-auth via current password, uniqueness check, resets emailVerified and " +
|
|
21
|
-
"expects the app to trigger the verification flow
|
|
28
|
+
"expects the app to trigger the verification flow; opt out with " +
|
|
29
|
+
"`allowEmailChange: false`, which drops handler and section). Change-password and " +
|
|
22
30
|
"change-email stay custom `EditExtensionSection` components (re-auth flows a " +
|
|
23
31
|
"declarative action can't express); account deletion (user-data-rights " +
|
|
24
32
|
"request/cancel with grace period) is declarative fields + actions. Apps " +
|
|
@@ -38,9 +46,7 @@ export function createUserProfileFeature(): FeatureDefinition {
|
|
|
38
46
|
r.requires("user-data-rights");
|
|
39
47
|
r.requires("user-data-rights-defaults");
|
|
40
48
|
|
|
41
|
-
const handlers = {
|
|
42
|
-
changeEmail: r.writeHandler(changeEmailWrite),
|
|
43
|
-
};
|
|
49
|
+
const handlers = allowEmailChange ? { changeEmail: r.writeHandler(changeEmailWrite) } : {};
|
|
44
50
|
|
|
45
51
|
// Boot-validator requires a "screen:<id>.title" key per r.screen(), plus
|
|
46
52
|
// one entry per i18nKey()-marked dot-form label below — kept inline (not
|
|
@@ -100,12 +106,16 @@ export function createUserProfileFeature(): FeatureDefinition {
|
|
|
100
106
|
},
|
|
101
107
|
layout: {
|
|
102
108
|
sections: [
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
+
...(allowEmailChange
|
|
110
|
+
? [
|
|
111
|
+
{
|
|
112
|
+
kind: "extension" as const,
|
|
113
|
+
title: i18nKey("profile.email.title"),
|
|
114
|
+
component: { react: { __component: CHANGE_EMAIL_SECTION_EXTENSION_NAME } },
|
|
115
|
+
entityName: "user",
|
|
116
|
+
},
|
|
117
|
+
]
|
|
118
|
+
: []),
|
|
109
119
|
{
|
|
110
120
|
kind: "extension",
|
|
111
121
|
title: i18nKey("profile.password.title"),
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"source": "samples/apps/use-all-bundled APP_FEATURES (composeFeatures includeBundled + signup)",
|
|
3
|
-
"featureCount":
|
|
3
|
+
"featureCount": 82,
|
|
4
4
|
"features": [
|
|
5
5
|
{
|
|
6
6
|
"name": "admin-shell",
|
|
@@ -2072,6 +2072,32 @@
|
|
|
2072
2072
|
"recommended": false
|
|
2073
2073
|
}
|
|
2074
2074
|
},
|
|
2075
|
+
{
|
|
2076
|
+
"name": "renderer-pdf",
|
|
2077
|
+
"description": "Renderer plugin for `kind=\"document-pdf\"` that draws a table document (title, meta lines, weighted columns, repeating header, page footer) as a PDF in pure JavaScript, without a browser or Puppeteer service. `payload.variables` must be a `PdfTableDocument`. Requires `renderer-foundation`; mount a Puppeteer-based plugin instead when you need HTML/CSS layouts.",
|
|
2078
|
+
"toggleableDefault": null,
|
|
2079
|
+
"requires": [
|
|
2080
|
+
"renderer-foundation"
|
|
2081
|
+
],
|
|
2082
|
+
"optionalRequires": [],
|
|
2083
|
+
"configReads": [],
|
|
2084
|
+
"exposesApis": [],
|
|
2085
|
+
"usesApis": [],
|
|
2086
|
+
"extensionsUsed": [
|
|
2087
|
+
{
|
|
2088
|
+
"extensionName": "renderer",
|
|
2089
|
+
"entityName": "pdf"
|
|
2090
|
+
}
|
|
2091
|
+
],
|
|
2092
|
+
"configKeys": [],
|
|
2093
|
+
"secrets": [],
|
|
2094
|
+
"writeHandlers": [],
|
|
2095
|
+
"uiHints": {
|
|
2096
|
+
"displayLabel": "Renderer · PDF tables",
|
|
2097
|
+
"category": "notifications",
|
|
2098
|
+
"recommended": false
|
|
2099
|
+
}
|
|
2100
|
+
},
|
|
2075
2101
|
{
|
|
2076
2102
|
"name": "renderer-simple",
|
|
2077
2103
|
"description": "Default renderer plugin for `kind=\"notification\"`: takes a structured `EmailTemplateData` variable map (with `header`, `sections[]` of text/button objects, and optional `footer`; falls back to `title`/`body` if no structured fields are present) and returns rendered HTML with inline CSS. Requires `renderer-foundation`; sufficient for plain notification emails — swap it for `renderer-mail-html` if you need MJML/Markdown layouts.",
|
|
@@ -3054,7 +3080,7 @@
|
|
|
3054
3080
|
},
|
|
3055
3081
|
{
|
|
3056
3082
|
"name": "user-profile",
|
|
3057
|
-
"description": "Self-service account page (fw#2312: declarative `projectionDetail` bound to the signed-in user's own `me` row) plus its `change-email` write handler (re-auth via current password, uniqueness check, resets emailVerified and expects the app to trigger the verification flow). Change-password and change-email stay custom `EditExtensionSection` components (re-auth flows a declarative action can't express); account deletion (user-data-rights request/cancel with grace period) is declarative fields + actions. Apps place the screen (id \"profile\") in their logged-in area via r.nav — no `type: \"custom\"` / `__component` registration needed on the app side anymore. Requires `user`, `auth-email-password`, `user-data-rights`, and `user-data-rights-defaults` (so the GDPR boot-validator finds export/delete hooks for `user`'s PII fields once user-data-rights is mounted).",
|
|
3083
|
+
"description": "Self-service account page (fw#2312: declarative `projectionDetail` bound to the signed-in user's own `me` row) plus its `change-email` write handler (re-auth via current password, uniqueness check, resets emailVerified and expects the app to trigger the verification flow; opt out with `allowEmailChange: false`, which drops handler and section). Change-password and change-email stay custom `EditExtensionSection` components (re-auth flows a declarative action can't express); account deletion (user-data-rights request/cancel with grace period) is declarative fields + actions. Apps place the screen (id \"profile\") in their logged-in area via r.nav — no `type: \"custom\"` / `__component` registration needed on the app side anymore. Requires `user`, `auth-email-password`, `user-data-rights`, and `user-data-rights-defaults` (so the GDPR boot-validator finds export/delete hooks for `user`'s PII fields once user-data-rights is mounted).",
|
|
3058
3084
|
"toggleableDefault": null,
|
|
3059
3085
|
"requires": [
|
|
3060
3086
|
"user",
|
|
@@ -322,6 +322,11 @@ const FEATURE_IMPORT_REGISTRY: Record<string, FeatureImport> = {
|
|
|
322
322
|
path: "@cosmicdrift/kumiko-bundled-features/renderer-simple",
|
|
323
323
|
factory: "createRendererSimpleFeature",
|
|
324
324
|
},
|
|
325
|
+
"renderer-pdf": {
|
|
326
|
+
kind: "factory",
|
|
327
|
+
path: "@cosmicdrift/kumiko-bundled-features/renderer-pdf",
|
|
328
|
+
factory: "createRendererPdfFeature",
|
|
329
|
+
},
|
|
325
330
|
"rate-limiting": {
|
|
326
331
|
kind: "factory",
|
|
327
332
|
path: "@cosmicdrift/kumiko-bundled-features/rate-limiting",
|
|
@@ -76,6 +76,7 @@ import { createPersonalAccessTokensFeature } from "@cosmicdrift/kumiko-bundled-f
|
|
|
76
76
|
import { securityBaselineFeatures } from "@cosmicdrift/kumiko-bundled-features/presets";
|
|
77
77
|
import { readinessFeature } from "@cosmicdrift/kumiko-bundled-features/readiness";
|
|
78
78
|
import { createRendererFoundationFeature } from "@cosmicdrift/kumiko-bundled-features/renderer-foundation";
|
|
79
|
+
import { createRendererPdfFeature } from "@cosmicdrift/kumiko-bundled-features/renderer-pdf";
|
|
79
80
|
import { createRendererSimpleFeature } from "@cosmicdrift/kumiko-bundled-features/renderer-simple";
|
|
80
81
|
import { createSecretsFeature } from "@cosmicdrift/kumiko-bundled-features/secrets";
|
|
81
82
|
import { createSeoFeature } from "@cosmicdrift/kumiko-bundled-features/seo";
|
|
@@ -336,6 +337,7 @@ export const APP_FEATURES = [
|
|
|
336
337
|
collectionLabelsFeature,
|
|
337
338
|
createRendererFoundationFeature(),
|
|
338
339
|
createRendererSimpleFeature(),
|
|
340
|
+
createRendererPdfFeature(),
|
|
339
341
|
|
|
340
342
|
// managed-pages: requires config (auto-bundled). Smoke resolver never serves
|
|
341
343
|
// (boot-only). allowCustomCss:true boot-validates the CSS code path + emits
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# PDF Audit Export
|
|
2
|
+
|
|
3
|
+
A write handler exports tenant data as a PDF and keeps it as a FileRef.
|
|
4
|
+
|
|
5
|
+
## What it shows
|
|
6
|
+
|
|
7
|
+
- `renderTablePdf` (`@cosmicdrift/kumiko-bundled-features/renderer-pdf`) draws a table document in pure JavaScript: repeating header, weighted columns, page footer. The handler passes ready-formatted strings.
|
|
8
|
+
- `createFileRefFromBytes` (`@cosmicdrift/kumiko-framework/files`) stores the bytes like an upload (validation, tenant-prefixed storage key, `fileRef.created` event); the tenant comes from the caller.
|
|
9
|
+
- The result is a normal FileRef: `GET /api/files/:id` serves it with `Content-Type: application/pdf`, other tenants get 404.
|
|
10
|
+
|
|
11
|
+
## Source
|
|
12
|
+
|
|
13
|
+
Feature entry point: `src/feature.ts`. The export reads at most 200 inspections per call.
|
|
14
|
+
|
|
15
|
+
## Tests
|
|
16
|
+
|
|
17
|
+
Needs a running Postgres and `TEST_DATABASE_URL` set (e.g. `postgres://postgres:postgres@127.0.0.1:5432/postgres`, see `demo/.env.example`).
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
cd samples/recipes/pdf-audit-export
|
|
21
|
+
bun test
|
|
22
|
+
```
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@cosmicdrift/kumiko-sample-pdf-audit-export",
|
|
3
|
+
"description": "Exports an audit table as a PDF: a write handler renders rows with renderTablePdf and stores the bytes as a FileRef via createFileRefFromBytes.",
|
|
4
|
+
"private": true,
|
|
5
|
+
"dependencies": {
|
|
6
|
+
"@cosmicdrift/kumiko-bundled-features": "workspace:*",
|
|
7
|
+
"@cosmicdrift/kumiko-framework": "workspace:*"
|
|
8
|
+
},
|
|
9
|
+
"devDependencies": {
|
|
10
|
+
"unpdf": "^1.8.1"
|
|
11
|
+
}
|
|
12
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// PDF Audit Export Sample
|
|
2
|
+
//
|
|
3
|
+
// A write handler turns tenant data into a PDF and keeps it as a regular
|
|
4
|
+
// FileRef: renderTablePdf draws the table (pure JS, no browser), then
|
|
5
|
+
// createFileRefFromBytes stores the bytes through the same validation,
|
|
6
|
+
// storage layout and fileRef.created event as an upload.
|
|
7
|
+
|
|
8
|
+
import { renderTablePdf } from "@cosmicdrift/kumiko-bundled-features/renderer-pdf";
|
|
9
|
+
import {
|
|
10
|
+
createDateField,
|
|
11
|
+
createEntity,
|
|
12
|
+
createEntityExecutor,
|
|
13
|
+
createSelectField,
|
|
14
|
+
createTextField,
|
|
15
|
+
defineFeature,
|
|
16
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
17
|
+
import { createFileRefFromBytes } from "@cosmicdrift/kumiko-framework/files";
|
|
18
|
+
import * as z from "zod";
|
|
19
|
+
|
|
20
|
+
export const INSPECTION_RESULTS = ["ok", "defect"] as const;
|
|
21
|
+
|
|
22
|
+
export const inspectionEntity = createEntity({
|
|
23
|
+
table: "read_sample_inspections",
|
|
24
|
+
fields: {
|
|
25
|
+
assetName: createTextField({ personal: false, reason: "is_business_data", required: true }),
|
|
26
|
+
inspectedOn: createDateField({ required: true }),
|
|
27
|
+
result: createSelectField({ options: INSPECTION_RESULTS, required: true }),
|
|
28
|
+
inspector: createTextField({ personal: "ref" }),
|
|
29
|
+
},
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
const RESULT_LABELS: Readonly<Record<(typeof INSPECTION_RESULTS)[number], string>> = {
|
|
33
|
+
ok: "In Ordnung",
|
|
34
|
+
defect: "Mangel",
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
// The renderer takes ready-formatted strings, so date formatting stays with the caller.
|
|
38
|
+
function formatGermanDate(isoDate: string): string {
|
|
39
|
+
const [year, month, day] = isoDate.split("-");
|
|
40
|
+
return `${day}.${month}.${year}`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function toFileNameSegment(text: string): string {
|
|
44
|
+
return text.replaceAll(/[^A-Za-z0-9_-]+/g, "-");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const AUDIT_ROW_LIMIT = 200;
|
|
48
|
+
|
|
49
|
+
const access = { access: { roles: ["Admin", "User"] } } as const;
|
|
50
|
+
|
|
51
|
+
export const pdfAuditExportFeature = defineFeature("pdf-audit-export", (r) => {
|
|
52
|
+
r.entity("inspection", inspectionEntity);
|
|
53
|
+
const { executor: inspectionExecutor } = createEntityExecutor("inspection", inspectionEntity);
|
|
54
|
+
|
|
55
|
+
r.writeHandler(
|
|
56
|
+
"inspection:create",
|
|
57
|
+
z.object({
|
|
58
|
+
assetName: z.string().min(1),
|
|
59
|
+
inspectedOn: z.iso.date(),
|
|
60
|
+
result: z.enum(INSPECTION_RESULTS),
|
|
61
|
+
inspector: z.string().optional(),
|
|
62
|
+
}),
|
|
63
|
+
async (event, ctx) => inspectionExecutor.create(event.payload, event.user, ctx.db),
|
|
64
|
+
access,
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
r.writeHandler(
|
|
68
|
+
"export-audit",
|
|
69
|
+
z.object({ assetName: z.string().min(1), from: z.iso.date(), to: z.iso.date() }),
|
|
70
|
+
async (event, ctx) => {
|
|
71
|
+
const { assetName, from, to } = event.payload;
|
|
72
|
+
const inspections = await inspectionExecutor.list(
|
|
73
|
+
{
|
|
74
|
+
filters: [
|
|
75
|
+
{ field: "assetName", op: "eq", value: assetName },
|
|
76
|
+
{ field: "inspectedOn", op: "gte", value: from },
|
|
77
|
+
{ field: "inspectedOn", op: "lte", value: to },
|
|
78
|
+
],
|
|
79
|
+
sort: "inspectedOn",
|
|
80
|
+
sortDirection: "asc",
|
|
81
|
+
limit: AUDIT_ROW_LIMIT,
|
|
82
|
+
},
|
|
83
|
+
event.user,
|
|
84
|
+
ctx.db,
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
const { pdfBytes } = await renderTablePdf({
|
|
88
|
+
title: `Prüfprotokoll ${assetName}`,
|
|
89
|
+
meta: [
|
|
90
|
+
{ label: "Zeitraum", value: `${formatGermanDate(from)} bis ${formatGermanDate(to)}` },
|
|
91
|
+
{ label: "Prüfungen", value: String(inspections.rows.length) },
|
|
92
|
+
],
|
|
93
|
+
columns: [{ header: "Datum" }, { header: "Ergebnis" }, { header: "Prüfer", weight: 2 }],
|
|
94
|
+
rows: inspections.rows.map((row) => [
|
|
95
|
+
formatGermanDate(String(row["inspectedOn"])),
|
|
96
|
+
RESULT_LABELS[z.enum(INSPECTION_RESULTS).parse(row["result"])],
|
|
97
|
+
String(row["inspector"] ?? ""),
|
|
98
|
+
]),
|
|
99
|
+
emptyText: "Keine Prüfungen im Zeitraum",
|
|
100
|
+
footerText: `Prüfbericht ${assetName}`,
|
|
101
|
+
pageLabel: "Seite {page} von {pages}",
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
const created = await createFileRefFromBytes(ctx, {
|
|
105
|
+
fileName: `audit-${toFileNameSegment(assetName)}.pdf`,
|
|
106
|
+
mimeType: "application/pdf",
|
|
107
|
+
data: pdfBytes,
|
|
108
|
+
});
|
|
109
|
+
if (!created.isSuccess) return created;
|
|
110
|
+
return { isSuccess: true as const, data: { fileRefId: created.data.id } };
|
|
111
|
+
},
|
|
112
|
+
access,
|
|
113
|
+
);
|
|
114
|
+
});
|