@skalfa/skalfa-app 1.0.0 → 1.0.2
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/.env.example +43 -43
- package/.github/workflows/publish.yml +39 -0
- package/CONTRIBUTING.md +45 -0
- package/LICENSE +21 -0
- package/README.md +91 -28
- package/app/auth/edit/page.tsx +65 -65
- package/app/auth/login/page.tsx +63 -63
- package/app/auth/me/page.tsx +58 -58
- package/app/auth/register/page.tsx +69 -69
- package/app/auth/verify/page.tsx +53 -53
- package/app/dashboard/user/page.tsx +76 -76
- package/app/layout.tsx +37 -37
- package/app/manifest.ts +25 -0
- package/app/page.tsx +13 -13
- package/barrels.json +5 -5
- package/blueprints/starter.blueprint.json +102 -102
- package/bun.lock +916 -0
- package/components/base.components/chip/Chip.component.tsx +39 -39
- package/components/base.components/document/DocumentViewer.component.tsx +163 -163
- package/components/base.components/document/ExportExcel.component.tsx +340 -340
- package/components/base.components/document/ImportExcel.component.tsx +315 -315
- package/components/base.components/document/PrintTable.component.tsx +204 -204
- package/components/base.components/document/RenderPDF.component.tsx +415 -415
- package/components/base.components/input/Checkbox.component.tsx +109 -109
- package/components/base.components/input/Input.component.tsx +332 -332
- package/components/base.components/input/InputCheckbox.component.tsx +174 -174
- package/components/base.components/input/InputCurrency.component.tsx +163 -163
- package/components/base.components/input/InputDate.component.tsx +352 -352
- package/components/base.components/input/InputDatetime.component.tsx +260 -260
- package/components/base.components/input/InputDocument.component.tsx +351 -351
- package/components/base.components/input/InputImage.component.tsx +533 -533
- package/components/base.components/input/InputMap.component.tsx +317 -317
- package/components/base.components/input/InputNumber.component.tsx +192 -192
- package/components/base.components/input/InputOtp.component.tsx +169 -169
- package/components/base.components/input/InputPassword.component.tsx +236 -236
- package/components/base.components/input/InputRadio.component.tsx +175 -175
- package/components/base.components/input/InputTime.component.tsx +275 -275
- package/components/base.components/input/InputValues.component.tsx +68 -68
- package/components/base.components/input/Radio.component.tsx +102 -102
- package/components/base.components/input/Select.component.tsx +541 -541
- package/components/base.components/modal/BottomSheet.component.tsx +245 -245
- package/components/base.components/supervision/FormSupervision.component.tsx +433 -433
- package/components/base.components/supervision/TableSupervision.component.tsx +697 -697
- package/components/base.components/table/ControlBar.component.tsx +497 -497
- package/components/base.components/table/FilterComponent.tsx +518 -518
- package/components/base.components/table/Table.component.tsx +469 -469
- package/components/base.components/typography/TypographyArticle.component.tsx +26 -26
- package/components/base.components/typography/TypographyColumn.component.tsx +20 -20
- package/components/base.components/typography/TypographyContent.component.tsx +20 -20
- package/components/base.components/typography/TypographyTips.component.tsx +20 -20
- package/components/base.components/wrap/Draggable.component.tsx +303 -303
- package/components/base.components/wrap/IDBProvider.tsx +12 -12
- package/components/base.components/wrap/Image.component.tsx +9 -9
- package/components/base.components/wrap/ShortcutProvider.tsx +57 -57
- package/components/base.components/wrap/Swipe.component.tsx +93 -93
- package/components/index.ts +2 -2
- package/contexts/AppProvider.tsx +11 -11
- package/contexts/Auth.context.tsx +64 -64
- package/contexts/Toggle.context.tsx +44 -44
- package/next.config.ts +15 -1
- package/package.json +14 -13
- package/public/204.svg +19 -19
- package/public/500.svg +39 -39
- package/public/icon-192.png +0 -0
- package/public/icon-512.png +0 -0
- package/public/images/logo-fill.png +0 -0
- package/public/images/logo-full-fill.png +0 -0
- package/public/images/logo-full.png +0 -0
- package/public/images/logo.png +0 -0
- package/schema/idb/app.schema.ts +8 -8
- package/src-tauri/Cargo.toml +14 -0
- package/src-tauri/build.rs +3 -0
- package/src-tauri/capabilities/default.json +11 -0
- package/src-tauri/icons/128x128.png +0 -0
- package/src-tauri/icons/128x128@2x.png +0 -0
- package/src-tauri/icons/32x32.png +0 -0
- package/src-tauri/icons/icon.icns +0 -0
- package/src-tauri/icons/icon.ico +0 -0
- package/src-tauri/src/main.rs +7 -0
- package/src-tauri/tauri.conf.json +36 -0
- package/styles/globals.css +231 -231
- package/styles/tailwind.safelist +68 -68
- package/utils/commands/barrels.ts +27 -27
- package/utils/commands/light.ts +21 -21
- package/utils/commands/logger.ts +42 -42
- package/utils/commands/stubs/table-blueprint.stub +12 -12
- package/utils/commands/use-pdf.ts +29 -29
|
@@ -1,416 +1,416 @@
|
|
|
1
|
-
"use client"
|
|
2
|
-
|
|
3
|
-
import { PDFDocument, StandardFonts, PDFPage, rgb } from 'pdf-lib'
|
|
4
|
-
import { useEffect, useRef } from 'react'
|
|
5
|
-
|
|
6
|
-
export const PaperSize = {
|
|
7
|
-
LETTER: { width: 612, height: 792 },
|
|
8
|
-
A4: { width: 595, height: 842 },
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
export type RenderPDFProps = {
|
|
12
|
-
content: PageSchema[]
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
export type PageSchema = {
|
|
16
|
-
page: {
|
|
17
|
-
size?: keyof typeof PaperSize | { width: number; height: number }
|
|
18
|
-
margin?: number
|
|
19
|
-
content: NodeSchema[]
|
|
20
|
-
}
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
export type Style = {
|
|
24
|
-
width?: number
|
|
25
|
-
height?: number
|
|
26
|
-
|
|
27
|
-
padding?: number
|
|
28
|
-
paddingTop?: number
|
|
29
|
-
paddingRight?: number
|
|
30
|
-
paddingBottom?: number
|
|
31
|
-
paddingLeft?: number
|
|
32
|
-
paddingX?: number
|
|
33
|
-
paddingY?: number
|
|
34
|
-
|
|
35
|
-
marginTop?: number
|
|
36
|
-
marginBottom?: number
|
|
37
|
-
|
|
38
|
-
fontSize?: number
|
|
39
|
-
fontWeight?: "normal" | "bold"
|
|
40
|
-
lineHeight?: number
|
|
41
|
-
letterSpacing?: number
|
|
42
|
-
color?: string
|
|
43
|
-
opacity?: number
|
|
44
|
-
align?: "left" | "center" | "right"
|
|
45
|
-
textTransform?: "uppercase" | "lowercase" | "capitalize"
|
|
46
|
-
|
|
47
|
-
backgroundColor?: string
|
|
48
|
-
borderColor?: string
|
|
49
|
-
borderWidth?: number
|
|
50
|
-
|
|
51
|
-
underline?: boolean
|
|
52
|
-
|
|
53
|
-
textAlign?: "left" | "center" | "right"
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
export type NodeSchema =
|
|
57
|
-
| { type: "view"; style?: Style; content: NodeSchema[] }
|
|
58
|
-
| { type: "text"; content: string; style?: Style }
|
|
59
|
-
| { type: "image"; src: string | Uint8Array | ArrayBuffer; style?: Style }
|
|
60
|
-
| { type: "table"; content: NodeSchema[] }
|
|
61
|
-
| { type: "tr"; content: NodeSchema[], style?: Style }
|
|
62
|
-
| { type: 'td' | 'th'; content: NodeSchema[] | string; style?: Style }
|
|
63
|
-
|
|
64
|
-
// ==================================================
|
|
65
|
-
// Layout Context
|
|
66
|
-
// ==================================================
|
|
67
|
-
|
|
68
|
-
class LayoutContext {
|
|
69
|
-
x: number
|
|
70
|
-
y: number
|
|
71
|
-
constructor(
|
|
72
|
-
public width: number,
|
|
73
|
-
public height: number,
|
|
74
|
-
public margin: number
|
|
75
|
-
) {
|
|
76
|
-
this.x = margin
|
|
77
|
-
this.y = height - margin
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
needBreak(h: number) {
|
|
81
|
-
return this.y - h < this.margin
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
reset() {
|
|
85
|
-
this.x = this.margin
|
|
86
|
-
this.y = this.height - this.margin
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
// ==================================================
|
|
91
|
-
// Helpers
|
|
92
|
-
// ==================================================
|
|
93
|
-
|
|
94
|
-
function resolvePadding(style?: Style) {
|
|
95
|
-
const p = style?.padding ?? 0
|
|
96
|
-
const px = style?.paddingX ?? p
|
|
97
|
-
const py = style?.paddingY ?? p
|
|
98
|
-
|
|
99
|
-
return {
|
|
100
|
-
top: style?.paddingTop ?? py,
|
|
101
|
-
bottom: style?.paddingBottom ?? py,
|
|
102
|
-
left: style?.paddingLeft ?? px,
|
|
103
|
-
right: style?.paddingRight ?? px,
|
|
104
|
-
}
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
function resolveText(text: string, style?: Style) {
|
|
108
|
-
if (!style?.textTransform) return text
|
|
109
|
-
if (style.textTransform === "uppercase") return text.toUpperCase()
|
|
110
|
-
if (style.textTransform === "lowercase") return text.toLowerCase()
|
|
111
|
-
if (style.textTransform === "capitalize")
|
|
112
|
-
return text.replace(/\b\w/g, c => c.toUpperCase())
|
|
113
|
-
return text
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
function hexToRgb(hex?: string) {
|
|
117
|
-
if (!hex) return undefined
|
|
118
|
-
const h = hex.replace("#", "")
|
|
119
|
-
return rgb(
|
|
120
|
-
parseInt(h.slice(0, 2), 16) / 255,
|
|
121
|
-
parseInt(h.slice(2, 4), 16) / 255,
|
|
122
|
-
parseInt(h.slice(4, 6), 16) / 255
|
|
123
|
-
)
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
async function embedImage(pdf: PDFDocument, bytes: Uint8Array | ArrayBuffer) {
|
|
127
|
-
const data = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes)
|
|
128
|
-
try {
|
|
129
|
-
return await pdf.embedPng(data)
|
|
130
|
-
} catch {
|
|
131
|
-
return await pdf.embedJpg(data)
|
|
132
|
-
}
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
async function resolveImageSource(
|
|
136
|
-
src: string | Uint8Array | ArrayBuffer
|
|
137
|
-
): Promise<Uint8Array> {
|
|
138
|
-
if (src instanceof Uint8Array) return src
|
|
139
|
-
if (src instanceof ArrayBuffer) return new Uint8Array(src)
|
|
140
|
-
|
|
141
|
-
const res = await fetch(src)
|
|
142
|
-
if (!res.ok) {
|
|
143
|
-
throw new Error(`Failed to load image: ${src}`)
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
const buffer = await res.arrayBuffer()
|
|
147
|
-
return new Uint8Array(buffer)
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
function normalizeContent(
|
|
152
|
-
content: string | NodeSchema[]
|
|
153
|
-
): NodeSchema[] {
|
|
154
|
-
if (typeof content === "string") {
|
|
155
|
-
return [
|
|
156
|
-
{
|
|
157
|
-
type: "text",
|
|
158
|
-
content
|
|
159
|
-
}
|
|
160
|
-
]
|
|
161
|
-
}
|
|
162
|
-
return content
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
// ==================================================
|
|
167
|
-
// Render Engine
|
|
168
|
-
// ==================================================
|
|
169
|
-
|
|
170
|
-
export async function RenderPDF(
|
|
171
|
-
{ content }: RenderPDFProps
|
|
172
|
-
): Promise<Uint8Array> {
|
|
173
|
-
|
|
174
|
-
const pdf = await PDFDocument.create()
|
|
175
|
-
|
|
176
|
-
const fontRegular = await pdf.embedFont(StandardFonts.Courier)
|
|
177
|
-
const fontBold = await pdf.embedFont(StandardFonts.CourierBold)
|
|
178
|
-
|
|
179
|
-
for (const p of content) {
|
|
180
|
-
const size =
|
|
181
|
-
typeof p.page.size === "string"
|
|
182
|
-
? PaperSize[p.page.size]
|
|
183
|
-
: p.page.size ?? PaperSize.A4
|
|
184
|
-
|
|
185
|
-
const margin = p.page.margin ?? 40
|
|
186
|
-
|
|
187
|
-
let page: PDFPage = pdf.addPage([size.width, size.height])
|
|
188
|
-
const ctx = new LayoutContext(size.width, size.height, margin)
|
|
189
|
-
|
|
190
|
-
const draw = async (node: NodeSchema) => {
|
|
191
|
-
|
|
192
|
-
// ===================== VIEW =====================
|
|
193
|
-
if (node.type === "view") {
|
|
194
|
-
const pad = resolvePadding(node.style)
|
|
195
|
-
const startY = ctx.y
|
|
196
|
-
|
|
197
|
-
ctx.y -= pad.top
|
|
198
|
-
ctx.x += pad.left
|
|
199
|
-
|
|
200
|
-
for (const c of node.content) await draw(c)
|
|
201
|
-
|
|
202
|
-
const endY = ctx.y
|
|
203
|
-
const boxHeight = startY - endY
|
|
204
|
-
|
|
205
|
-
if (node.style?.backgroundColor) {
|
|
206
|
-
page.drawRectangle({
|
|
207
|
-
x: ctx.margin,
|
|
208
|
-
y: endY,
|
|
209
|
-
width: size.width - ctx.margin * 2,
|
|
210
|
-
height: boxHeight,
|
|
211
|
-
color: hexToRgb(node.style.backgroundColor),
|
|
212
|
-
})
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
if (node.style?.borderWidth && node.style?.borderColor) {
|
|
216
|
-
page.drawRectangle({
|
|
217
|
-
x: ctx.margin,
|
|
218
|
-
y: endY,
|
|
219
|
-
width: size.width - ctx.margin * 2,
|
|
220
|
-
height: boxHeight,
|
|
221
|
-
borderColor: hexToRgb(node.style.borderColor),
|
|
222
|
-
borderWidth: node.style.borderWidth,
|
|
223
|
-
})
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
ctx.x -= pad.left
|
|
227
|
-
ctx.y -= pad.bottom
|
|
228
|
-
return
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
// ===================== TEXT =====================
|
|
232
|
-
if (node.type === "text") {
|
|
233
|
-
const style = node.style
|
|
234
|
-
const fs = style?.fontSize ?? 12
|
|
235
|
-
const lh = style?.lineHeight ?? fs + 4
|
|
236
|
-
|
|
237
|
-
if (ctx.needBreak(lh)) {
|
|
238
|
-
page = pdf.addPage([size.width, size.height])
|
|
239
|
-
ctx.reset()
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
const font =
|
|
243
|
-
style?.fontWeight === "bold"
|
|
244
|
-
? fontBold
|
|
245
|
-
: fontRegular
|
|
246
|
-
|
|
247
|
-
const text = resolveText(node.content, style)
|
|
248
|
-
const color = hexToRgb(style?.color)
|
|
249
|
-
|
|
250
|
-
let x = ctx.x
|
|
251
|
-
if (style?.align === "center") {
|
|
252
|
-
const w = font.widthOfTextAtSize(text, fs)
|
|
253
|
-
x = (size.width - w) / 2
|
|
254
|
-
}
|
|
255
|
-
if (style?.align === "right") {
|
|
256
|
-
const w = font.widthOfTextAtSize(text, fs)
|
|
257
|
-
x = size.width - ctx.margin - w
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
page.drawText(text, {
|
|
261
|
-
x,
|
|
262
|
-
y: ctx.y - fs,
|
|
263
|
-
size: fs,
|
|
264
|
-
font,
|
|
265
|
-
color,
|
|
266
|
-
opacity: style?.opacity,
|
|
267
|
-
})
|
|
268
|
-
|
|
269
|
-
if (style?.underline) {
|
|
270
|
-
const w = font.widthOfTextAtSize(text, fs)
|
|
271
|
-
page.drawLine({
|
|
272
|
-
start: { x, y: ctx.y - fs - 2 },
|
|
273
|
-
end: { x: x + w, y: ctx.y - fs - 2 },
|
|
274
|
-
thickness: 1,
|
|
275
|
-
})
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
ctx.y -= lh + (style?.marginBottom ?? 0)
|
|
279
|
-
return
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
// ===================== IMAGE =====================
|
|
283
|
-
if (node.type === "image") {
|
|
284
|
-
const bytes = await resolveImageSource(node.src)
|
|
285
|
-
const img = await embedImage(pdf, bytes)
|
|
286
|
-
const base = img.scale(1)
|
|
287
|
-
|
|
288
|
-
let w = node.style?.width ?? base.width
|
|
289
|
-
let h = node.style?.height ?? base.height
|
|
290
|
-
|
|
291
|
-
if (node.style?.width && !node.style?.height)
|
|
292
|
-
h = (base.height / base.width) * w
|
|
293
|
-
|
|
294
|
-
if (node.style?.height && !node.style?.width)
|
|
295
|
-
w = (base.width / base.height) * h
|
|
296
|
-
|
|
297
|
-
if (ctx.needBreak(h)) {
|
|
298
|
-
page = pdf.addPage([size.width, size.height])
|
|
299
|
-
ctx.reset()
|
|
300
|
-
}
|
|
301
|
-
|
|
302
|
-
page.drawImage(img, {
|
|
303
|
-
x: ctx.x,
|
|
304
|
-
y: ctx.y - h,
|
|
305
|
-
width: w,
|
|
306
|
-
height: h,
|
|
307
|
-
})
|
|
308
|
-
|
|
309
|
-
ctx.y -= h + (node.style?.marginBottom ?? 0)
|
|
310
|
-
return
|
|
311
|
-
}
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
// ===================== TABLE =====================
|
|
315
|
-
if (node.type === "table") {
|
|
316
|
-
for (const r of node.content) await draw(r)
|
|
317
|
-
ctx.y -= 8
|
|
318
|
-
return
|
|
319
|
-
}
|
|
320
|
-
|
|
321
|
-
if (node.type === "tr") {
|
|
322
|
-
const rowH = node.style?.height || 20;
|
|
323
|
-
|
|
324
|
-
if (ctx.needBreak(rowH)) {
|
|
325
|
-
page = pdf.addPage([size.width, size.height])
|
|
326
|
-
ctx.reset()
|
|
327
|
-
}
|
|
328
|
-
|
|
329
|
-
const tableWidth = size.width - ctx.margin * 2
|
|
330
|
-
const colCount = node.content.length
|
|
331
|
-
const colWidth = tableWidth / colCount
|
|
332
|
-
|
|
333
|
-
const originalX = ctx.x
|
|
334
|
-
let x = ctx.margin
|
|
335
|
-
|
|
336
|
-
for (const cell of node.content) {
|
|
337
|
-
if (cell.type !== "td" && cell.type !== "th") continue
|
|
338
|
-
|
|
339
|
-
const children = normalizeContent(cell.content)
|
|
340
|
-
const pad = resolvePadding(cell.style)
|
|
341
|
-
const startY = ctx.y
|
|
342
|
-
|
|
343
|
-
ctx.x = x + pad.left
|
|
344
|
-
ctx.y -= pad.top
|
|
345
|
-
|
|
346
|
-
for (const child of children) {
|
|
347
|
-
await draw(child)
|
|
348
|
-
}
|
|
349
|
-
|
|
350
|
-
ctx.y = startY
|
|
351
|
-
x += colWidth
|
|
352
|
-
}
|
|
353
|
-
|
|
354
|
-
ctx.x = originalX
|
|
355
|
-
ctx.y -= rowH
|
|
356
|
-
return
|
|
357
|
-
}
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
}
|
|
363
|
-
|
|
364
|
-
for (const n of p.page.content) await draw(n)
|
|
365
|
-
}
|
|
366
|
-
|
|
367
|
-
return await pdf.save()
|
|
368
|
-
}
|
|
369
|
-
|
|
370
|
-
export function RenderPDFPreview({ schema, className }: { schema: PageSchema[], className?: string }) {
|
|
371
|
-
const canvasRef = useRef<HTMLCanvasElement>(null);
|
|
372
|
-
|
|
373
|
-
useEffect(() => {
|
|
374
|
-
let cancelled = false;
|
|
375
|
-
|
|
376
|
-
(async () => {
|
|
377
|
-
const bytes = await RenderPDF({ content: schema });
|
|
378
|
-
const pdfjs = await import("pdfjs-dist/legacy/build/pdf.mjs");
|
|
379
|
-
pdfjs.GlobalWorkerOptions.workerSrc = "/pdf.worker.min.mjs";
|
|
380
|
-
|
|
381
|
-
const pdf = await pdfjs.getDocument({ data: bytes }).promise;
|
|
382
|
-
if (cancelled) return;
|
|
383
|
-
|
|
384
|
-
const page = await pdf.getPage(1);
|
|
385
|
-
const dpr = 1;
|
|
386
|
-
const viewport = page.getViewport({ scale: 1 });
|
|
387
|
-
|
|
388
|
-
const canvas = canvasRef.current!;
|
|
389
|
-
const ctx = canvas.getContext("2d")!;
|
|
390
|
-
canvas.style.width = `${viewport.width}px`;
|
|
391
|
-
canvas.style.height = `${viewport.height}px`
|
|
392
|
-
canvas.width = viewport.width;
|
|
393
|
-
canvas.height = viewport.height;
|
|
394
|
-
|
|
395
|
-
const scaledViewport = page.getViewport({ scale: dpr });
|
|
396
|
-
|
|
397
|
-
const renderTask = page.render({
|
|
398
|
-
canvas,
|
|
399
|
-
canvasContext: ctx,
|
|
400
|
-
viewport: scaledViewport,
|
|
401
|
-
});
|
|
402
|
-
|
|
403
|
-
await renderTask.promise;
|
|
404
|
-
})();
|
|
405
|
-
|
|
406
|
-
return () => {
|
|
407
|
-
cancelled = true;
|
|
408
|
-
};
|
|
409
|
-
}, [schema]);
|
|
410
|
-
|
|
411
|
-
return <>
|
|
412
|
-
<div className={className}>
|
|
413
|
-
<canvas ref={canvasRef} className="w-full border" />
|
|
414
|
-
</div>
|
|
415
|
-
</>
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { PDFDocument, StandardFonts, PDFPage, rgb } from 'pdf-lib'
|
|
4
|
+
import { useEffect, useRef } from 'react'
|
|
5
|
+
|
|
6
|
+
export const PaperSize = {
|
|
7
|
+
LETTER: { width: 612, height: 792 },
|
|
8
|
+
A4: { width: 595, height: 842 },
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export type RenderPDFProps = {
|
|
12
|
+
content: PageSchema[]
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export type PageSchema = {
|
|
16
|
+
page: {
|
|
17
|
+
size?: keyof typeof PaperSize | { width: number; height: number }
|
|
18
|
+
margin?: number
|
|
19
|
+
content: NodeSchema[]
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export type Style = {
|
|
24
|
+
width?: number
|
|
25
|
+
height?: number
|
|
26
|
+
|
|
27
|
+
padding?: number
|
|
28
|
+
paddingTop?: number
|
|
29
|
+
paddingRight?: number
|
|
30
|
+
paddingBottom?: number
|
|
31
|
+
paddingLeft?: number
|
|
32
|
+
paddingX?: number
|
|
33
|
+
paddingY?: number
|
|
34
|
+
|
|
35
|
+
marginTop?: number
|
|
36
|
+
marginBottom?: number
|
|
37
|
+
|
|
38
|
+
fontSize?: number
|
|
39
|
+
fontWeight?: "normal" | "bold"
|
|
40
|
+
lineHeight?: number
|
|
41
|
+
letterSpacing?: number
|
|
42
|
+
color?: string
|
|
43
|
+
opacity?: number
|
|
44
|
+
align?: "left" | "center" | "right"
|
|
45
|
+
textTransform?: "uppercase" | "lowercase" | "capitalize"
|
|
46
|
+
|
|
47
|
+
backgroundColor?: string
|
|
48
|
+
borderColor?: string
|
|
49
|
+
borderWidth?: number
|
|
50
|
+
|
|
51
|
+
underline?: boolean
|
|
52
|
+
|
|
53
|
+
textAlign?: "left" | "center" | "right"
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export type NodeSchema =
|
|
57
|
+
| { type: "view"; style?: Style; content: NodeSchema[] }
|
|
58
|
+
| { type: "text"; content: string; style?: Style }
|
|
59
|
+
| { type: "image"; src: string | Uint8Array | ArrayBuffer; style?: Style }
|
|
60
|
+
| { type: "table"; content: NodeSchema[] }
|
|
61
|
+
| { type: "tr"; content: NodeSchema[], style?: Style }
|
|
62
|
+
| { type: 'td' | 'th'; content: NodeSchema[] | string; style?: Style }
|
|
63
|
+
|
|
64
|
+
// ==================================================
|
|
65
|
+
// Layout Context
|
|
66
|
+
// ==================================================
|
|
67
|
+
|
|
68
|
+
class LayoutContext {
|
|
69
|
+
x: number
|
|
70
|
+
y: number
|
|
71
|
+
constructor(
|
|
72
|
+
public width: number,
|
|
73
|
+
public height: number,
|
|
74
|
+
public margin: number
|
|
75
|
+
) {
|
|
76
|
+
this.x = margin
|
|
77
|
+
this.y = height - margin
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
needBreak(h: number) {
|
|
81
|
+
return this.y - h < this.margin
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
reset() {
|
|
85
|
+
this.x = this.margin
|
|
86
|
+
this.y = this.height - this.margin
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ==================================================
|
|
91
|
+
// Helpers
|
|
92
|
+
// ==================================================
|
|
93
|
+
|
|
94
|
+
function resolvePadding(style?: Style) {
|
|
95
|
+
const p = style?.padding ?? 0
|
|
96
|
+
const px = style?.paddingX ?? p
|
|
97
|
+
const py = style?.paddingY ?? p
|
|
98
|
+
|
|
99
|
+
return {
|
|
100
|
+
top: style?.paddingTop ?? py,
|
|
101
|
+
bottom: style?.paddingBottom ?? py,
|
|
102
|
+
left: style?.paddingLeft ?? px,
|
|
103
|
+
right: style?.paddingRight ?? px,
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function resolveText(text: string, style?: Style) {
|
|
108
|
+
if (!style?.textTransform) return text
|
|
109
|
+
if (style.textTransform === "uppercase") return text.toUpperCase()
|
|
110
|
+
if (style.textTransform === "lowercase") return text.toLowerCase()
|
|
111
|
+
if (style.textTransform === "capitalize")
|
|
112
|
+
return text.replace(/\b\w/g, c => c.toUpperCase())
|
|
113
|
+
return text
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function hexToRgb(hex?: string) {
|
|
117
|
+
if (!hex) return undefined
|
|
118
|
+
const h = hex.replace("#", "")
|
|
119
|
+
return rgb(
|
|
120
|
+
parseInt(h.slice(0, 2), 16) / 255,
|
|
121
|
+
parseInt(h.slice(2, 4), 16) / 255,
|
|
122
|
+
parseInt(h.slice(4, 6), 16) / 255
|
|
123
|
+
)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async function embedImage(pdf: PDFDocument, bytes: Uint8Array | ArrayBuffer) {
|
|
127
|
+
const data = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes)
|
|
128
|
+
try {
|
|
129
|
+
return await pdf.embedPng(data)
|
|
130
|
+
} catch {
|
|
131
|
+
return await pdf.embedJpg(data)
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function resolveImageSource(
|
|
136
|
+
src: string | Uint8Array | ArrayBuffer
|
|
137
|
+
): Promise<Uint8Array> {
|
|
138
|
+
if (src instanceof Uint8Array) return src
|
|
139
|
+
if (src instanceof ArrayBuffer) return new Uint8Array(src)
|
|
140
|
+
|
|
141
|
+
const res = await fetch(src)
|
|
142
|
+
if (!res.ok) {
|
|
143
|
+
throw new Error(`Failed to load image: ${src}`)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const buffer = await res.arrayBuffer()
|
|
147
|
+
return new Uint8Array(buffer)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
function normalizeContent(
|
|
152
|
+
content: string | NodeSchema[]
|
|
153
|
+
): NodeSchema[] {
|
|
154
|
+
if (typeof content === "string") {
|
|
155
|
+
return [
|
|
156
|
+
{
|
|
157
|
+
type: "text",
|
|
158
|
+
content
|
|
159
|
+
}
|
|
160
|
+
]
|
|
161
|
+
}
|
|
162
|
+
return content
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
// ==================================================
|
|
167
|
+
// Render Engine
|
|
168
|
+
// ==================================================
|
|
169
|
+
|
|
170
|
+
export async function RenderPDF(
|
|
171
|
+
{ content }: RenderPDFProps
|
|
172
|
+
): Promise<Uint8Array> {
|
|
173
|
+
|
|
174
|
+
const pdf = await PDFDocument.create()
|
|
175
|
+
|
|
176
|
+
const fontRegular = await pdf.embedFont(StandardFonts.Courier)
|
|
177
|
+
const fontBold = await pdf.embedFont(StandardFonts.CourierBold)
|
|
178
|
+
|
|
179
|
+
for (const p of content) {
|
|
180
|
+
const size =
|
|
181
|
+
typeof p.page.size === "string"
|
|
182
|
+
? PaperSize[p.page.size]
|
|
183
|
+
: p.page.size ?? PaperSize.A4
|
|
184
|
+
|
|
185
|
+
const margin = p.page.margin ?? 40
|
|
186
|
+
|
|
187
|
+
let page: PDFPage = pdf.addPage([size.width, size.height])
|
|
188
|
+
const ctx = new LayoutContext(size.width, size.height, margin)
|
|
189
|
+
|
|
190
|
+
const draw = async (node: NodeSchema) => {
|
|
191
|
+
|
|
192
|
+
// ===================== VIEW =====================
|
|
193
|
+
if (node.type === "view") {
|
|
194
|
+
const pad = resolvePadding(node.style)
|
|
195
|
+
const startY = ctx.y
|
|
196
|
+
|
|
197
|
+
ctx.y -= pad.top
|
|
198
|
+
ctx.x += pad.left
|
|
199
|
+
|
|
200
|
+
for (const c of node.content) await draw(c)
|
|
201
|
+
|
|
202
|
+
const endY = ctx.y
|
|
203
|
+
const boxHeight = startY - endY
|
|
204
|
+
|
|
205
|
+
if (node.style?.backgroundColor) {
|
|
206
|
+
page.drawRectangle({
|
|
207
|
+
x: ctx.margin,
|
|
208
|
+
y: endY,
|
|
209
|
+
width: size.width - ctx.margin * 2,
|
|
210
|
+
height: boxHeight,
|
|
211
|
+
color: hexToRgb(node.style.backgroundColor),
|
|
212
|
+
})
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (node.style?.borderWidth && node.style?.borderColor) {
|
|
216
|
+
page.drawRectangle({
|
|
217
|
+
x: ctx.margin,
|
|
218
|
+
y: endY,
|
|
219
|
+
width: size.width - ctx.margin * 2,
|
|
220
|
+
height: boxHeight,
|
|
221
|
+
borderColor: hexToRgb(node.style.borderColor),
|
|
222
|
+
borderWidth: node.style.borderWidth,
|
|
223
|
+
})
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
ctx.x -= pad.left
|
|
227
|
+
ctx.y -= pad.bottom
|
|
228
|
+
return
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// ===================== TEXT =====================
|
|
232
|
+
if (node.type === "text") {
|
|
233
|
+
const style = node.style
|
|
234
|
+
const fs = style?.fontSize ?? 12
|
|
235
|
+
const lh = style?.lineHeight ?? fs + 4
|
|
236
|
+
|
|
237
|
+
if (ctx.needBreak(lh)) {
|
|
238
|
+
page = pdf.addPage([size.width, size.height])
|
|
239
|
+
ctx.reset()
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const font =
|
|
243
|
+
style?.fontWeight === "bold"
|
|
244
|
+
? fontBold
|
|
245
|
+
: fontRegular
|
|
246
|
+
|
|
247
|
+
const text = resolveText(node.content, style)
|
|
248
|
+
const color = hexToRgb(style?.color)
|
|
249
|
+
|
|
250
|
+
let x = ctx.x
|
|
251
|
+
if (style?.align === "center") {
|
|
252
|
+
const w = font.widthOfTextAtSize(text, fs)
|
|
253
|
+
x = (size.width - w) / 2
|
|
254
|
+
}
|
|
255
|
+
if (style?.align === "right") {
|
|
256
|
+
const w = font.widthOfTextAtSize(text, fs)
|
|
257
|
+
x = size.width - ctx.margin - w
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
page.drawText(text, {
|
|
261
|
+
x,
|
|
262
|
+
y: ctx.y - fs,
|
|
263
|
+
size: fs,
|
|
264
|
+
font,
|
|
265
|
+
color,
|
|
266
|
+
opacity: style?.opacity,
|
|
267
|
+
})
|
|
268
|
+
|
|
269
|
+
if (style?.underline) {
|
|
270
|
+
const w = font.widthOfTextAtSize(text, fs)
|
|
271
|
+
page.drawLine({
|
|
272
|
+
start: { x, y: ctx.y - fs - 2 },
|
|
273
|
+
end: { x: x + w, y: ctx.y - fs - 2 },
|
|
274
|
+
thickness: 1,
|
|
275
|
+
})
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
ctx.y -= lh + (style?.marginBottom ?? 0)
|
|
279
|
+
return
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// ===================== IMAGE =====================
|
|
283
|
+
if (node.type === "image") {
|
|
284
|
+
const bytes = await resolveImageSource(node.src)
|
|
285
|
+
const img = await embedImage(pdf, bytes)
|
|
286
|
+
const base = img.scale(1)
|
|
287
|
+
|
|
288
|
+
let w = node.style?.width ?? base.width
|
|
289
|
+
let h = node.style?.height ?? base.height
|
|
290
|
+
|
|
291
|
+
if (node.style?.width && !node.style?.height)
|
|
292
|
+
h = (base.height / base.width) * w
|
|
293
|
+
|
|
294
|
+
if (node.style?.height && !node.style?.width)
|
|
295
|
+
w = (base.width / base.height) * h
|
|
296
|
+
|
|
297
|
+
if (ctx.needBreak(h)) {
|
|
298
|
+
page = pdf.addPage([size.width, size.height])
|
|
299
|
+
ctx.reset()
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
page.drawImage(img, {
|
|
303
|
+
x: ctx.x,
|
|
304
|
+
y: ctx.y - h,
|
|
305
|
+
width: w,
|
|
306
|
+
height: h,
|
|
307
|
+
})
|
|
308
|
+
|
|
309
|
+
ctx.y -= h + (node.style?.marginBottom ?? 0)
|
|
310
|
+
return
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
// ===================== TABLE =====================
|
|
315
|
+
if (node.type === "table") {
|
|
316
|
+
for (const r of node.content) await draw(r)
|
|
317
|
+
ctx.y -= 8
|
|
318
|
+
return
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
if (node.type === "tr") {
|
|
322
|
+
const rowH = node.style?.height || 20;
|
|
323
|
+
|
|
324
|
+
if (ctx.needBreak(rowH)) {
|
|
325
|
+
page = pdf.addPage([size.width, size.height])
|
|
326
|
+
ctx.reset()
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const tableWidth = size.width - ctx.margin * 2
|
|
330
|
+
const colCount = node.content.length
|
|
331
|
+
const colWidth = tableWidth / colCount
|
|
332
|
+
|
|
333
|
+
const originalX = ctx.x
|
|
334
|
+
let x = ctx.margin
|
|
335
|
+
|
|
336
|
+
for (const cell of node.content) {
|
|
337
|
+
if (cell.type !== "td" && cell.type !== "th") continue
|
|
338
|
+
|
|
339
|
+
const children = normalizeContent(cell.content)
|
|
340
|
+
const pad = resolvePadding(cell.style)
|
|
341
|
+
const startY = ctx.y
|
|
342
|
+
|
|
343
|
+
ctx.x = x + pad.left
|
|
344
|
+
ctx.y -= pad.top
|
|
345
|
+
|
|
346
|
+
for (const child of children) {
|
|
347
|
+
await draw(child)
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
ctx.y = startY
|
|
351
|
+
x += colWidth
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
ctx.x = originalX
|
|
355
|
+
ctx.y -= rowH
|
|
356
|
+
return
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
for (const n of p.page.content) await draw(n)
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
return await pdf.save()
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
export function RenderPDFPreview({ schema, className }: { schema: PageSchema[], className?: string }) {
|
|
371
|
+
const canvasRef = useRef<HTMLCanvasElement>(null);
|
|
372
|
+
|
|
373
|
+
useEffect(() => {
|
|
374
|
+
let cancelled = false;
|
|
375
|
+
|
|
376
|
+
(async () => {
|
|
377
|
+
const bytes = await RenderPDF({ content: schema });
|
|
378
|
+
const pdfjs = await import("pdfjs-dist/legacy/build/pdf.mjs");
|
|
379
|
+
pdfjs.GlobalWorkerOptions.workerSrc = "/pdf.worker.min.mjs";
|
|
380
|
+
|
|
381
|
+
const pdf = await pdfjs.getDocument({ data: bytes }).promise;
|
|
382
|
+
if (cancelled) return;
|
|
383
|
+
|
|
384
|
+
const page = await pdf.getPage(1);
|
|
385
|
+
const dpr = 1;
|
|
386
|
+
const viewport = page.getViewport({ scale: 1 });
|
|
387
|
+
|
|
388
|
+
const canvas = canvasRef.current!;
|
|
389
|
+
const ctx = canvas.getContext("2d")!;
|
|
390
|
+
canvas.style.width = `${viewport.width}px`;
|
|
391
|
+
canvas.style.height = `${viewport.height}px`
|
|
392
|
+
canvas.width = viewport.width;
|
|
393
|
+
canvas.height = viewport.height;
|
|
394
|
+
|
|
395
|
+
const scaledViewport = page.getViewport({ scale: dpr });
|
|
396
|
+
|
|
397
|
+
const renderTask = page.render({
|
|
398
|
+
canvas,
|
|
399
|
+
canvasContext: ctx,
|
|
400
|
+
viewport: scaledViewport,
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
await renderTask.promise;
|
|
404
|
+
})();
|
|
405
|
+
|
|
406
|
+
return () => {
|
|
407
|
+
cancelled = true;
|
|
408
|
+
};
|
|
409
|
+
}, [schema]);
|
|
410
|
+
|
|
411
|
+
return <>
|
|
412
|
+
<div className={className}>
|
|
413
|
+
<canvas ref={canvasRef} className="w-full border" />
|
|
414
|
+
</div>
|
|
415
|
+
</>
|
|
416
416
|
}
|