@causyne/agent 1.10.21 → 1.11.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/native-skills/media/professional-document-publishing/SKILL.md +44 -0
- package/native-skills/media/screenplay-and-shotlist-forge/SKILL.md +42 -0
- package/package.json +1 -1
- package/src/acp/client.js +235 -0
- package/src/acp/client.ts +282 -0
- package/src/acp/peerProfiles.js +122 -0
- package/src/acp/peerProfiles.ts +128 -0
- package/src/acp/protocol.js +54 -0
- package/src/acp/protocol.ts +434 -0
- package/src/acp/server.js +609 -0
- package/src/acp/server.ts +676 -0
- package/src/agentLoop.d.ts +43 -0
- package/src/agentLoop.js +142 -8
- package/src/commands.js +203 -0
- package/src/compiledSkills.js +10 -0
- package/src/documentEngine/antiSlopAuditor.js +156 -0
- package/src/documentEngine/antiSlopAuditor.ts +221 -0
- package/src/documentEngine/documentForge.js +443 -0
- package/src/documentEngine/documentForge.ts +519 -0
- package/src/documentEngine/screenplayFormatter.js +336 -0
- package/src/documentEngine/screenplayFormatter.ts +407 -0
- package/src/entrypoint.js +13 -0
- package/src/execSafe.js +10 -13
- package/src/execSafe.ts +25 -0
- package/src/modelRouter.js +56 -123
- package/src/modelRouter.ts +134 -0
- package/src/observer/advisory.js +164 -0
- package/src/observer/advisory.ts +171 -0
- package/src/observer/coldObserver.js +462 -0
- package/src/observer/coldObserver.ts +540 -0
- package/src/observer/evidence.js +435 -0
- package/src/observer/evidence.ts +499 -0
- package/src/perception.js +303 -118
- package/src/perception.ts +387 -0
- package/src/procSignal.js +15 -16
- package/src/procSignal.ts +31 -0
- package/src/promptMentions.js +170 -0
- package/src/promptMentions.ts +212 -0
- package/src/sandbox.js +1 -1
- package/src/textDiff.js +117 -109
- package/src/textDiff.ts +169 -0
- package/src/toolPacks.js +5 -2
- package/src/tools/documentForge.js +215 -0
- package/src/tools/documentForge.ts +265 -0
- package/src/tools/peerAgent.js +102 -0
- package/src/tools/peerAgent.ts +116 -0
- package/src/tools/registry.js +3 -1
- package/src/tui/completion.js +97 -41
- package/src/tui/prompt.js +17 -17
- package/src/uncertainty.js +93 -205
- package/src/uncertainty.ts +255 -0
- package/src/utils/paths.js +15 -18
- package/src/utils/paths.ts +36 -0
- package/src/vfeTracker.js +122 -181
- package/src/vfeTracker.ts +242 -0
|
@@ -0,0 +1,519 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright (c) 2026 Causyne.
|
|
3
|
+
* SPDX-License-Identifier: BUSL-1.1
|
|
4
|
+
*
|
|
5
|
+
* Causyne Document Forge — HTML/CSS-First Publishing, Headless Chrome PDF Compiler & Visual QA.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import fs from 'node:fs';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
import os from 'node:os';
|
|
11
|
+
import { spawn } from 'node:child_process';
|
|
12
|
+
|
|
13
|
+
export type DocumentPalette = 'corporate' | 'editorial_dair' | 'minimal_clean';
|
|
14
|
+
|
|
15
|
+
export interface FormalDocumentSection {
|
|
16
|
+
title?: string;
|
|
17
|
+
subtitle?: string;
|
|
18
|
+
paragraphs?: string[];
|
|
19
|
+
table?: {
|
|
20
|
+
headers: string[];
|
|
21
|
+
rows: string[][];
|
|
22
|
+
};
|
|
23
|
+
callout?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface FormalDocumentSpec {
|
|
27
|
+
title: string;
|
|
28
|
+
subtitle?: string;
|
|
29
|
+
categoryOrKicker?: string;
|
|
30
|
+
authorOrProposer?: string;
|
|
31
|
+
palette?: DocumentPalette;
|
|
32
|
+
sections: FormalDocumentSection[];
|
|
33
|
+
includeCover?: boolean;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface RenderPdfResult {
|
|
37
|
+
success: boolean;
|
|
38
|
+
pdfPath?: string;
|
|
39
|
+
fileSizeBytes?: number;
|
|
40
|
+
sizeKb?: number;
|
|
41
|
+
exceeds2Mb?: boolean;
|
|
42
|
+
error?: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface PageDensityResult {
|
|
46
|
+
pageNumber: number;
|
|
47
|
+
fillPercentage: number;
|
|
48
|
+
isSparse: boolean; // <25% filled
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface InspectPagesResult {
|
|
52
|
+
success: boolean;
|
|
53
|
+
pageCount?: number;
|
|
54
|
+
densities?: PageDensityResult[];
|
|
55
|
+
imagePaths?: string[];
|
|
56
|
+
sparsePages?: number[];
|
|
57
|
+
error?: string;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const PALETTES: Record<DocumentPalette, { primary: string; primaryDark: string; accent: string; bg: string; text: string; rule: string }> = {
|
|
61
|
+
corporate: {
|
|
62
|
+
primary: '#0f172a',
|
|
63
|
+
primaryDark: '#020617',
|
|
64
|
+
accent: '#2563eb',
|
|
65
|
+
bg: '#ffffff',
|
|
66
|
+
text: '#1e293b',
|
|
67
|
+
rule: '#e2e8f0'
|
|
68
|
+
},
|
|
69
|
+
editorial_dair: {
|
|
70
|
+
primary: '#78281F', // Maroon
|
|
71
|
+
primaryDark: '#5C1E17',
|
|
72
|
+
accent: '#D4AB0D', // Gold
|
|
73
|
+
bg: '#FDF5E6', // Cream
|
|
74
|
+
text: '#1B2833', // Ink
|
|
75
|
+
rule: '#DCD3C4'
|
|
76
|
+
},
|
|
77
|
+
minimal_clean: {
|
|
78
|
+
primary: '#18181b',
|
|
79
|
+
primaryDark: '#09090b',
|
|
80
|
+
accent: '#0284c7',
|
|
81
|
+
bg: '#ffffff',
|
|
82
|
+
text: '#27272a',
|
|
83
|
+
rule: '#e4e4e7'
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Compile formal publication document to pixel-perfect HTML + CSS Paged Media.
|
|
89
|
+
*/
|
|
90
|
+
export function compileDocumentHtml(spec: FormalDocumentSpec): { html: string; css: string } {
|
|
91
|
+
const p = PALETTES[spec.palette || 'corporate'];
|
|
92
|
+
|
|
93
|
+
const css = `
|
|
94
|
+
:root {
|
|
95
|
+
--primary: ${p.primary};
|
|
96
|
+
--primary-dk: ${p.primaryDark};
|
|
97
|
+
--accent: ${p.accent};
|
|
98
|
+
--bg: ${p.bg};
|
|
99
|
+
--text: ${p.text};
|
|
100
|
+
--rule: ${p.rule};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
@page {
|
|
104
|
+
size: A4 portrait;
|
|
105
|
+
margin: 20mm 18mm 18mm 18mm;
|
|
106
|
+
@bottom-right {
|
|
107
|
+
content: "Halaman " counter(page);
|
|
108
|
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
|
109
|
+
font-size: 8.5pt;
|
|
110
|
+
color: #64748b;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
@page cover {
|
|
114
|
+
margin: 0;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
* { box-sizing: border-box; }
|
|
118
|
+
html {
|
|
119
|
+
-webkit-print-color-adjust: exact;
|
|
120
|
+
print-color-adjust: exact;
|
|
121
|
+
}
|
|
122
|
+
body {
|
|
123
|
+
margin: 0;
|
|
124
|
+
padding: 0;
|
|
125
|
+
font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
|
|
126
|
+
font-size: 10pt;
|
|
127
|
+
line-height: 1.55;
|
|
128
|
+
color: var(--text);
|
|
129
|
+
background: #ffffff;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
p {
|
|
133
|
+
margin: 0 0 8pt;
|
|
134
|
+
text-align: justify;
|
|
135
|
+
}
|
|
136
|
+
h1, h2, h3 {
|
|
137
|
+
page-break-after: avoid;
|
|
138
|
+
break-after: avoid;
|
|
139
|
+
}
|
|
140
|
+
h2 {
|
|
141
|
+
font-size: 13pt;
|
|
142
|
+
font-weight: 700;
|
|
143
|
+
color: var(--primary);
|
|
144
|
+
border-bottom: 1.5px solid var(--primary);
|
|
145
|
+
padding-bottom: 4pt;
|
|
146
|
+
margin-top: 16pt;
|
|
147
|
+
margin-bottom: 8pt;
|
|
148
|
+
}
|
|
149
|
+
h3 {
|
|
150
|
+
font-size: 11pt;
|
|
151
|
+
font-weight: 600;
|
|
152
|
+
color: var(--primary-dk);
|
|
153
|
+
margin-top: 12pt;
|
|
154
|
+
margin-bottom: 6pt;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
.cover-page {
|
|
158
|
+
page: cover;
|
|
159
|
+
width: 210mm;
|
|
160
|
+
height: 297mm;
|
|
161
|
+
padding: 0;
|
|
162
|
+
margin: 0;
|
|
163
|
+
display: flex;
|
|
164
|
+
flex-direction: column;
|
|
165
|
+
justify-content: space-between;
|
|
166
|
+
page-break-after: always;
|
|
167
|
+
background: #ffffff;
|
|
168
|
+
}
|
|
169
|
+
.cover-band-top {
|
|
170
|
+
height: 14mm;
|
|
171
|
+
background: linear-gradient(90deg, var(--primary-dk) 0%, var(--primary) 40%, var(--accent) 100%);
|
|
172
|
+
display: flex;
|
|
173
|
+
align-items: center;
|
|
174
|
+
justify-content: center;
|
|
175
|
+
}
|
|
176
|
+
.cover-band-bottom {
|
|
177
|
+
height: 14mm;
|
|
178
|
+
background: linear-gradient(90deg, var(--accent) 0%, var(--primary) 60%, var(--primary-dk) 100%);
|
|
179
|
+
}
|
|
180
|
+
.cover-kicker {
|
|
181
|
+
color: #ffffff;
|
|
182
|
+
font-size: 7.5pt;
|
|
183
|
+
letter-spacing: 0.3em;
|
|
184
|
+
font-weight: 600;
|
|
185
|
+
text-transform: uppercase;
|
|
186
|
+
}
|
|
187
|
+
.cover-content {
|
|
188
|
+
padding: 40mm 24mm;
|
|
189
|
+
display: flex;
|
|
190
|
+
flex-direction: column;
|
|
191
|
+
justify-content: center;
|
|
192
|
+
flex-grow: 1;
|
|
193
|
+
}
|
|
194
|
+
.cover-title {
|
|
195
|
+
font-size: 24pt;
|
|
196
|
+
font-weight: 800;
|
|
197
|
+
line-height: 1.25;
|
|
198
|
+
color: var(--primary);
|
|
199
|
+
letter-spacing: -0.02em;
|
|
200
|
+
margin-bottom: 12pt;
|
|
201
|
+
}
|
|
202
|
+
.cover-subtitle {
|
|
203
|
+
font-size: 13pt;
|
|
204
|
+
color: #475569;
|
|
205
|
+
line-height: 1.4;
|
|
206
|
+
margin-bottom: 30pt;
|
|
207
|
+
}
|
|
208
|
+
.cover-meta {
|
|
209
|
+
border-left: 3px solid var(--accent);
|
|
210
|
+
padding-left: 14pt;
|
|
211
|
+
font-size: 10.5pt;
|
|
212
|
+
color: #334155;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
.section-block {
|
|
216
|
+
margin-bottom: 16pt;
|
|
217
|
+
page-break-inside: auto;
|
|
218
|
+
}
|
|
219
|
+
.callout-box {
|
|
220
|
+
background: #f8fafc;
|
|
221
|
+
border-left: 3.5px solid var(--accent);
|
|
222
|
+
padding: 8pt 12pt;
|
|
223
|
+
margin: 10pt 0;
|
|
224
|
+
border-radius: 0 4px 4px 0;
|
|
225
|
+
page-break-inside: avoid;
|
|
226
|
+
font-size: 9.5pt;
|
|
227
|
+
}
|
|
228
|
+
table {
|
|
229
|
+
width: 100%;
|
|
230
|
+
border-collapse: collapse;
|
|
231
|
+
margin: 10pt 0;
|
|
232
|
+
page-break-inside: auto;
|
|
233
|
+
}
|
|
234
|
+
tr {
|
|
235
|
+
page-break-inside: avoid;
|
|
236
|
+
}
|
|
237
|
+
th {
|
|
238
|
+
background: var(--primary);
|
|
239
|
+
color: #ffffff;
|
|
240
|
+
font-weight: 600;
|
|
241
|
+
font-size: 8.5pt;
|
|
242
|
+
text-align: left;
|
|
243
|
+
padding: 6pt 8pt;
|
|
244
|
+
text-transform: uppercase;
|
|
245
|
+
letter-spacing: 0.04em;
|
|
246
|
+
}
|
|
247
|
+
td {
|
|
248
|
+
padding: 6pt 8pt;
|
|
249
|
+
border-bottom: 1px solid var(--rule);
|
|
250
|
+
font-size: 9pt;
|
|
251
|
+
vertical-align: top;
|
|
252
|
+
}
|
|
253
|
+
tr:nth-child(even) td {
|
|
254
|
+
background: rgba(0, 0, 0, 0.02);
|
|
255
|
+
}
|
|
256
|
+
`.trim();
|
|
257
|
+
|
|
258
|
+
let bodyHtml = '';
|
|
259
|
+
|
|
260
|
+
if (spec.includeCover !== false) {
|
|
261
|
+
bodyHtml += `
|
|
262
|
+
<div class="cover-page">
|
|
263
|
+
<div class="cover-band-top">
|
|
264
|
+
<div class="cover-kicker">${escapeHtml(spec.categoryOrKicker || 'OFFICIAL PROPOSAL & TECHNICAL REPORT')}</div>
|
|
265
|
+
</div>
|
|
266
|
+
<div class="cover-content">
|
|
267
|
+
<div class="cover-title">${escapeHtml(spec.title)}</div>
|
|
268
|
+
${spec.subtitle ? `<div class="cover-subtitle">${escapeHtml(spec.subtitle)}</div>` : ''}
|
|
269
|
+
${spec.authorOrProposer ? `<div class="cover-meta">Disusun oleh: <strong>${escapeHtml(spec.authorOrProposer)}</strong></div>` : ''}
|
|
270
|
+
</div>
|
|
271
|
+
<div class="cover-band-bottom"></div>
|
|
272
|
+
</div>`;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
bodyHtml += '<div class="document-content">';
|
|
276
|
+
for (const s of spec.sections) {
|
|
277
|
+
bodyHtml += '\n<section class="section-block">';
|
|
278
|
+
if (s.title) bodyHtml += `\n <h2>${escapeHtml(s.title)}</h2>`;
|
|
279
|
+
if (s.subtitle) bodyHtml += `\n <h3>${escapeHtml(s.subtitle)}</h3>`;
|
|
280
|
+
if (s.callout) bodyHtml += `\n <div class="callout-box">${escapeHtml(s.callout)}</div>`;
|
|
281
|
+
|
|
282
|
+
if (s.paragraphs) {
|
|
283
|
+
for (const pText of s.paragraphs) {
|
|
284
|
+
bodyHtml += `\n <p>${escapeHtml(pText)}</p>`;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
if (s.table && s.table.headers && s.table.rows) {
|
|
289
|
+
bodyHtml += '\n <table>\n <thead>\n <tr>';
|
|
290
|
+
for (const h of s.table.headers) {
|
|
291
|
+
bodyHtml += `\n <th>${escapeHtml(h)}</th>`;
|
|
292
|
+
}
|
|
293
|
+
bodyHtml += '\n </tr>\n </thead>\n <tbody>';
|
|
294
|
+
for (const row of s.table.rows) {
|
|
295
|
+
bodyHtml += '\n <tr>';
|
|
296
|
+
for (const cell of row) {
|
|
297
|
+
bodyHtml += `\n <td>${escapeHtml(cell)}</td>`;
|
|
298
|
+
}
|
|
299
|
+
bodyHtml += '\n </tr>';
|
|
300
|
+
}
|
|
301
|
+
bodyHtml += '\n </tbody>\n </table>';
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
bodyHtml += '\n</section>';
|
|
305
|
+
}
|
|
306
|
+
bodyHtml += '\n</div>';
|
|
307
|
+
|
|
308
|
+
const html = `<!DOCTYPE html>
|
|
309
|
+
<html lang="id">
|
|
310
|
+
<head>
|
|
311
|
+
<meta charset="UTF-8">
|
|
312
|
+
<title>${escapeHtml(spec.title)}</title>
|
|
313
|
+
<style>${css}</style>
|
|
314
|
+
</head>
|
|
315
|
+
<body>
|
|
316
|
+
${bodyHtml}
|
|
317
|
+
</body>
|
|
318
|
+
</html>`;
|
|
319
|
+
|
|
320
|
+
return { html, css };
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Locate installed Google Chrome or Chromium executable across OS targets.
|
|
325
|
+
*/
|
|
326
|
+
export function findChromePath(): string | null {
|
|
327
|
+
const candidates = [
|
|
328
|
+
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
|
329
|
+
'/Applications/Chromium.app/Contents/MacOS/Chromium',
|
|
330
|
+
'/usr/bin/google-chrome',
|
|
331
|
+
'/usr/bin/google-chrome-stable',
|
|
332
|
+
'/usr/bin/chromium-browser',
|
|
333
|
+
'/usr/bin/chromium',
|
|
334
|
+
'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe',
|
|
335
|
+
'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe'
|
|
336
|
+
];
|
|
337
|
+
|
|
338
|
+
for (const c of candidates) {
|
|
339
|
+
if (fs.existsSync(c)) return c;
|
|
340
|
+
}
|
|
341
|
+
return null;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Compile HTML to pixel-perfect vector PDF via Headless Chrome with stability monitor.
|
|
346
|
+
*/
|
|
347
|
+
export async function renderHtmlToPdf(htmlFilePath: string, outputPdfPath: string, timeoutMs: number = 25000): Promise<RenderPdfResult> {
|
|
348
|
+
const chromePath = findChromePath();
|
|
349
|
+
if (!chromePath) {
|
|
350
|
+
return { success: false, error: 'Google Chrome / Chromium executable not found on system.' };
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
const absHtml = path.resolve(htmlFilePath);
|
|
354
|
+
const absOut = path.resolve(outputPdfPath);
|
|
355
|
+
|
|
356
|
+
if (!fs.existsSync(absHtml)) {
|
|
357
|
+
return { success: false, error: `HTML source file does not exist: ${absHtml}` };
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
fs.mkdirSync(path.dirname(absOut), { recursive: true });
|
|
361
|
+
if (fs.existsSync(absOut)) {
|
|
362
|
+
fs.unlinkSync(absOut);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
const profileDir = fs.mkdtempSync(path.join(os.tmpdir(), 'causyne-pdf-profile-'));
|
|
366
|
+
|
|
367
|
+
return new Promise((resolve) => {
|
|
368
|
+
const proc = spawn(
|
|
369
|
+
chromePath,
|
|
370
|
+
[
|
|
371
|
+
'--headless',
|
|
372
|
+
'--disable-gpu',
|
|
373
|
+
'--no-sandbox',
|
|
374
|
+
'--no-pdf-header-footer',
|
|
375
|
+
`--user-data-dir=${profileDir}`,
|
|
376
|
+
`--print-to-pdf=${absOut}`,
|
|
377
|
+
`file://${absHtml}`
|
|
378
|
+
],
|
|
379
|
+
{ stdio: 'ignore' }
|
|
380
|
+
);
|
|
381
|
+
|
|
382
|
+
let prevSize = -1;
|
|
383
|
+
let stableCount = 0;
|
|
384
|
+
const interval = 250;
|
|
385
|
+
let elapsed = 0;
|
|
386
|
+
|
|
387
|
+
const timer = setInterval(() => {
|
|
388
|
+
elapsed += interval;
|
|
389
|
+
if (fs.existsSync(absOut)) {
|
|
390
|
+
const stat = fs.statSync(absOut);
|
|
391
|
+
if (stat.size === prevSize && stat.size > 0) {
|
|
392
|
+
stableCount++;
|
|
393
|
+
if (stableCount >= 3) {
|
|
394
|
+
cleanupAndResolve(true);
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
} else {
|
|
398
|
+
stableCount = 0;
|
|
399
|
+
prevSize = stat.size;
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
if (elapsed >= timeoutMs) {
|
|
404
|
+
cleanupAndResolve(false, 'Render timed out waiting for PDF file stabilization.');
|
|
405
|
+
}
|
|
406
|
+
}, interval);
|
|
407
|
+
|
|
408
|
+
function cleanupAndResolve(success: boolean, error?: string) {
|
|
409
|
+
clearInterval(timer);
|
|
410
|
+
try {
|
|
411
|
+
proc.kill();
|
|
412
|
+
} catch {}
|
|
413
|
+
try {
|
|
414
|
+
fs.rmSync(profileDir, { recursive: true, force: true });
|
|
415
|
+
} catch {}
|
|
416
|
+
|
|
417
|
+
if (!success || !fs.existsSync(absOut)) {
|
|
418
|
+
resolve({ success: false, error: error || 'Failed to generate PDF.' });
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
const stat = fs.statSync(absOut);
|
|
423
|
+
const sizeKb = Math.round(stat.size / 1024);
|
|
424
|
+
const exceeds2Mb = sizeKb > 2048;
|
|
425
|
+
|
|
426
|
+
resolve({
|
|
427
|
+
success: true,
|
|
428
|
+
pdfPath: absOut,
|
|
429
|
+
fileSizeBytes: stat.size,
|
|
430
|
+
sizeKb,
|
|
431
|
+
exceeds2Mb
|
|
432
|
+
});
|
|
433
|
+
}
|
|
434
|
+
});
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* Inspect page count and visual density of generated PDF.
|
|
439
|
+
*/
|
|
440
|
+
export async function inspectPdfPages(pdfPath: string, outputImagesDir?: string): Promise<InspectPagesResult> {
|
|
441
|
+
const absPdf = path.resolve(pdfPath);
|
|
442
|
+
if (!fs.existsSync(absPdf)) {
|
|
443
|
+
return { success: false, error: `PDF file does not exist: ${absPdf}` };
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
const script = `
|
|
447
|
+
import fitz, sys, json, os
|
|
448
|
+
|
|
449
|
+
pdf_path = sys.argv[1]
|
|
450
|
+
img_dir = sys.argv[2] if len(sys.argv) > 2 and sys.argv[2] else None
|
|
451
|
+
|
|
452
|
+
try:
|
|
453
|
+
doc = fitz.open(pdf_path)
|
|
454
|
+
page_count = len(doc)
|
|
455
|
+
densities = []
|
|
456
|
+
sparse_pages = []
|
|
457
|
+
saved_images = []
|
|
458
|
+
|
|
459
|
+
for idx, page in enumerate(doc):
|
|
460
|
+
h = page.rect.height
|
|
461
|
+
blocks = page.get_text("blocks")
|
|
462
|
+
max_bottom = max([b[3] for b in blocks], default=0) if blocks else 0
|
|
463
|
+
fill_pct = round((max_bottom / h) * 100, 1) if h > 0 else 0
|
|
464
|
+
is_sparse = fill_pct < 25.0
|
|
465
|
+
densities.append({"pageNumber": idx + 1, "fillPercentage": fill_pct, "isSparse": is_sparse})
|
|
466
|
+
if is_sparse:
|
|
467
|
+
sparse_pages.append(idx + 1)
|
|
468
|
+
|
|
469
|
+
if img_dir:
|
|
470
|
+
os.makedirs(img_dir, exist_ok=True)
|
|
471
|
+
img_path = os.path.join(img_dir, f"page_{idx+1:02d}.png")
|
|
472
|
+
page.get_pixmap(dpi=120).save(img_path)
|
|
473
|
+
saved_images.append(img_path)
|
|
474
|
+
|
|
475
|
+
print(json.dumps({
|
|
476
|
+
"success": True,
|
|
477
|
+
"pageCount": page_count,
|
|
478
|
+
"densities": densities,
|
|
479
|
+
"sparsePages": sparse_pages,
|
|
480
|
+
"imagePaths": saved_images
|
|
481
|
+
}))
|
|
482
|
+
except Exception as e:
|
|
483
|
+
print(json.dumps({"success": False, "error": str(e)}))
|
|
484
|
+
`;
|
|
485
|
+
|
|
486
|
+
return new Promise((resolve) => {
|
|
487
|
+
const py = spawn('python3', ['-c', script, absPdf, outputImagesDir || '']);
|
|
488
|
+
let stdout = '';
|
|
489
|
+
let stderr = '';
|
|
490
|
+
|
|
491
|
+
py.stdout.on('data', (d) => {
|
|
492
|
+
stdout += d.toString();
|
|
493
|
+
});
|
|
494
|
+
py.stderr.on('data', (d) => {
|
|
495
|
+
stderr += d.toString();
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
py.on('close', (code) => {
|
|
499
|
+
if (code !== 0) {
|
|
500
|
+
resolve({ success: false, error: stderr.trim() || `Python process exited with code ${code}` });
|
|
501
|
+
return;
|
|
502
|
+
}
|
|
503
|
+
try {
|
|
504
|
+
const parsed = JSON.parse(stdout.trim());
|
|
505
|
+
resolve(parsed);
|
|
506
|
+
} catch (err: any) {
|
|
507
|
+
resolve({ success: false, error: `Failed to parse python output: ${err.message}` });
|
|
508
|
+
}
|
|
509
|
+
});
|
|
510
|
+
});
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
function escapeHtml(str: string): string {
|
|
514
|
+
return String(str || '')
|
|
515
|
+
.replace(/&/g, '&')
|
|
516
|
+
.replace(/</g, '<')
|
|
517
|
+
.replace(/>/g, '>')
|
|
518
|
+
.replace(/"/g, '"');
|
|
519
|
+
}
|