@scrymore/scry-deployer 0.11.1 → 0.12.0-next.20261003113319
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/README.md +3 -1
- package/lib/converter/bundle.js +298 -0
- package/lib/converter/convert.js +239 -0
- package/lib/converter/ids.js +57 -0
- package/lib/converter/index.js +42 -0
- package/lib/converter/pdf.js +101 -0
- package/lib/converter/privacy.js +370 -0
- package/lib/converter/psd.js +298 -0
- package/lib/converter/raster.js +107 -0
- package/lib/converter/safeopen.js +22 -0
- package/lib/converter/scan.js +184 -0
- package/lib/converter/tiff.js +72 -0
- package/lib/converter/verdicts.js +89 -0
- package/lib/importConvert.js +1 -0
- package/lib/importXmp.js +21 -7
- package/package.json +4 -2
package/README.md
CHANGED
|
@@ -97,6 +97,8 @@ This deploys your Storybook immediately without setting up GitHub Actions.
|
|
|
97
97
|
|
|
98
98
|
**You don't need to install anything!** Just use `npx` to run the init command:
|
|
99
99
|
|
|
100
|
+
Requires Node.js 20.9 or newer (Node 18 is end of life; use `@scrymore/scry-deployer@0.11` if you are stuck on it).
|
|
101
|
+
|
|
100
102
|
```bash
|
|
101
103
|
# From npm (recommended)
|
|
102
104
|
npx @scrymore/scry-deployer init --projectId xxx --apiKey yyy
|
|
@@ -787,7 +789,7 @@ See the complete workflow configuration: [`.github/workflows/deploy-pr-preview.y
|
|
|
787
789
|
Key workflow features:
|
|
788
790
|
- **Triggers**: `pull_request` with types `[opened, synchronize, reopened]`
|
|
789
791
|
- **Permissions**: `contents: read`, `pull-requests: write`
|
|
790
|
-
- **Node version**:
|
|
792
|
+
- **Node version**: 20 (configurable in workflow)
|
|
791
793
|
- **Comment management**: Smart update/create logic to avoid duplicate comments
|
|
792
794
|
|
|
793
795
|
#### Cleanup
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* buildBundle(...): converted files -> one SCF 1.0 bundle (`scf.json` + `images/`) for Scry Sync.
|
|
3
|
+
*
|
|
4
|
+
* What goes in (guarantee G2: no originals, no paths):
|
|
5
|
+
* - only the converted pictures (sRGB PNG/JPEG, <= 2048 px, <= 4 MB), never an original file;
|
|
6
|
+
* - ids from pictureId(folderUuid, relativePath[, page]) (a hash: no path or name can be read back);
|
|
7
|
+
* - image files named by the hash of the id, so no file name reaches the bundle as a path;
|
|
8
|
+
* - title = [folder label, file name without extension] (founder decision D2), `name` "Page N" for pages;
|
|
9
|
+
* - the `x-scry-sync` block: the allow-listed XMP fields (title, description, keywords, rating, label), each one
|
|
10
|
+
* kept UNCHANGED or DROPPED ENTIRELY by the structural rule in privacy.js (anything that carries a path
|
|
11
|
+
* separator, a drive, `file:`, the folder's location or the user's name is dropped, never edited), plus `origin`
|
|
12
|
+
* ({convertedFrom, verdict, appVersion} and nothing else) and, when a value was dropped, `notes`
|
|
13
|
+
* ([{code: 'metadata_dropped', fields: [field names]}], never the value; also on the file's result row), and
|
|
14
|
+
* when a kept field was cut to its XMP cap, {code: 'metadata_truncated', fields: [field names]}.
|
|
15
|
+
* `creator` is not read from XMP today; if a source ever supplies one it goes through the same rule.
|
|
16
|
+
* - the title's file part is the file name without its extension, as the user named it, when it passes the same
|
|
17
|
+
* rule as the XMP values (F157); a name that carries a location (a Mac colon path, a slash lookalike, the user's
|
|
18
|
+
* name) loses the offending words, or becomes `Untitled <6 hex>`, and `fileName` joins the `metadata_dropped`
|
|
19
|
+
* note; a file whose name holds a backslash or a control character is not captured (failed verdict `odd_name`).
|
|
20
|
+
* Every file that is not captured is counted in `counts.skipped` with its plain-language reason (G3).
|
|
21
|
+
* A bundle over 1 GiB (the server's limit) or over 10,000 captures (the SCF limit) is refused before upload.
|
|
22
|
+
*/
|
|
23
|
+
const crypto = require('crypto');
|
|
24
|
+
const fs = require('fs');
|
|
25
|
+
const path = require('path');
|
|
26
|
+
const { SCF_SCHEMA_URL } = require('../scf.js');
|
|
27
|
+
const { pictureId } = require('./ids.js');
|
|
28
|
+
const { convertFile } = require('./convert.js');
|
|
29
|
+
const { SKIP_REASON, FAILED, failed } = require('./verdicts.js');
|
|
30
|
+
const { makeLeakCheck, keepIfClean } = require('./privacy.js');
|
|
31
|
+
|
|
32
|
+
const SOURCE_KIND = 'x-scry-sync';
|
|
33
|
+
const VENDOR_KEY = 'x-scry-sync';
|
|
34
|
+
const MAX_BUNDLE_BYTES = 1024 * 1024 * 1024;
|
|
35
|
+
/** The SCF validator's per-bundle capture cap (BUNDLE_TOO_MANY_CAPTURES). */
|
|
36
|
+
const MAX_BUNDLE_CAPTURES = 10_000;
|
|
37
|
+
const TOO_MANY_MESSAGE = 'This folder would make more than 10,000 pictures, which is over the limit; link a smaller folder';
|
|
38
|
+
const TOO_BIG_MESSAGE = 'This folder is too big; link a smaller folder';
|
|
39
|
+
const CONVERTED_FROM = new Set(['png', 'jpeg', 'webp', 'heic', 'heif', 'tiff', 'psd', 'psb', 'pdf', 'ai']);
|
|
40
|
+
const APP_VERSION_RE = /^\d{1,4}\.\d{1,4}\.\d{1,6}(?:-[0-9A-Za-z.]{1,32})?$/;
|
|
41
|
+
const EXT_OF_FAMILY = { png: 'png', jpeg: 'jpg', webp: 'webp' };
|
|
42
|
+
|
|
43
|
+
class BundleTooBigError extends Error {
|
|
44
|
+
constructor() {
|
|
45
|
+
super(TOO_BIG_MESSAGE);
|
|
46
|
+
this.name = 'BundleTooBigError';
|
|
47
|
+
this.code = 'bundle_too_big';
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
class TooManyCapturesError extends Error {
|
|
52
|
+
constructor() {
|
|
53
|
+
super(TOO_MANY_MESSAGE);
|
|
54
|
+
this.name = 'TooManyCapturesError';
|
|
55
|
+
this.code = 'too_many_captures';
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** The `origin` block: which format a picture came from, its verdict and the app version. Nothing else. */
|
|
60
|
+
function originBlock({ format, verdict, appVersion }) {
|
|
61
|
+
const origin = {};
|
|
62
|
+
if (CONVERTED_FROM.has(format)) origin.convertedFrom = format;
|
|
63
|
+
if (verdict === 'faithful' || verdict === 'approximate') origin.verdict = verdict;
|
|
64
|
+
if (typeof appVersion === 'string' && APP_VERSION_RE.test(appVersion)) origin.appVersion = appVersion;
|
|
65
|
+
return origin;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** A name that cannot be a file name on every OS, or that would read as a path: it is refused, not rewritten. */
|
|
69
|
+
function oddFileName(rel) {
|
|
70
|
+
if (typeof rel !== 'string' || rel.length === 0 || rel.includes('\\')) return true;
|
|
71
|
+
for (let i = 0; i < rel.length; i += 1) {
|
|
72
|
+
const code = rel.charCodeAt(i);
|
|
73
|
+
if (code < 0x20 || code === 0x7f) return true;
|
|
74
|
+
}
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The allow-listed XMP text fields, each unchanged or dropped by the structural rule (privacy.js); a keyword list
|
|
80
|
+
* keeps the keywords that pass and drops the others one by one. Returns the kept fields and the NAMES of the fields
|
|
81
|
+
* that lost a value (never the values), for the `metadata_dropped` note.
|
|
82
|
+
*/
|
|
83
|
+
function cleanMeta(xmp, leaks) {
|
|
84
|
+
const meta = {};
|
|
85
|
+
const dropped = [];
|
|
86
|
+
for (const key of ['title', 'description', 'label', 'creator']) {
|
|
87
|
+
if (typeof xmp[key] !== 'string' || xmp[key].length === 0) continue;
|
|
88
|
+
const value = keepIfClean(xmp[key], leaks);
|
|
89
|
+
if (value) meta[key] = value;
|
|
90
|
+
else dropped.push(key);
|
|
91
|
+
}
|
|
92
|
+
if (Array.isArray(xmp.keywords)) {
|
|
93
|
+
const keywords = xmp.keywords.map((k) => keepIfClean(k, leaks)).filter(Boolean);
|
|
94
|
+
if (keywords.length) meta.keywords = keywords;
|
|
95
|
+
if (keywords.length < xmp.keywords.filter((k) => typeof k === 'string' && k.length > 0).length) dropped.push('keywords');
|
|
96
|
+
}
|
|
97
|
+
if (Number.isInteger(xmp.rating)) meta.rating = xmp.rating;
|
|
98
|
+
return { meta, dropped };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* The note that tells the person why a title, keyword or label vanished (F76): the code and the field names only.
|
|
103
|
+
* `folderLabel` is listed when the folder's label was dropped (the title is then the file name alone).
|
|
104
|
+
*/
|
|
105
|
+
function metadataNotes(dropped, truncated = []) {
|
|
106
|
+
const notes = [];
|
|
107
|
+
if (dropped.length) notes.push({ code: 'metadata_dropped', fields: dropped });
|
|
108
|
+
if (truncated.length) notes.push({ code: 'metadata_truncated', fields: truncated });
|
|
109
|
+
return notes.length ? notes : null;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const MAX_LABEL_LENGTH = 200;
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* A folder label is a display name: cut to its shown length FIRST (so a long label is never scanned whole, F109), then
|
|
116
|
+
* unchanged when it carries no location, otherwise dropped (the title is then the file name alone).
|
|
117
|
+
*/
|
|
118
|
+
function cleanLabel(label, leaks) {
|
|
119
|
+
if (typeof label !== 'string') return '';
|
|
120
|
+
return keepIfClean(Array.from(label).slice(0, MAX_LABEL_LENGTH).join(''), leaks) || '';
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function stemOf(rel) {
|
|
124
|
+
const base = rel.split('/').pop();
|
|
125
|
+
const dot = base.lastIndexOf('.');
|
|
126
|
+
return dot > 0 ? base.slice(0, dot) : base;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function sha256Hex(text) {
|
|
130
|
+
return crypto.createHash('sha256').update(text).digest('hex');
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* The title's file part, run through the same rule as every other free-text value (F157: a file named
|
|
135
|
+
* `Users:<user>:Desktop` (macOS shows `/` as `:`) or `\u2215home\u2215<user>\u2215x` is a path in disguise).
|
|
136
|
+
* - clean: the stem, exactly as the user named it;
|
|
137
|
+
* - otherwise the stem without its offending words (split on spaces; every word that carries a location is left
|
|
138
|
+
* out and the rest is checked again as a whole, so a location spread over several words cannot survive);
|
|
139
|
+
* - otherwise `Untitled <6 hex of the picture id>` (a hash: the same file keeps the same title, two files differ).
|
|
140
|
+
* The file is captured either way. Returns the title part and whether the name was changed (noted as `fileName`).
|
|
141
|
+
*/
|
|
142
|
+
function safeStem(rel, leaks, id) {
|
|
143
|
+
const stem = stemOf(rel) || 'Untitled';
|
|
144
|
+
if (!leaks(stem)) return { stem, changed: false };
|
|
145
|
+
const rest = stem.split(/\s+/).filter((word) => word && !leaks(word)).join(' ');
|
|
146
|
+
if (rest && !leaks(rest)) return { stem: rest, changed: true };
|
|
147
|
+
return { stem: `Untitled ${String(id).slice(0, 6)}`, changed: true };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* @param {object} args
|
|
152
|
+
* @param {string} args.folderUuid the synced folder's UUID (ids)
|
|
153
|
+
* @param {{root:string, files:Array<{abs:string, rel:string}>, refused?:Array}} args.scan scanFolder() result
|
|
154
|
+
* @param {string} args.outDir an empty directory to write the bundle into
|
|
155
|
+
* @param {string} args.appVersion the app version, for `origin` and `source.tool`
|
|
156
|
+
* @param {string} [args.folderLabel] display name of the folder (first title segment)
|
|
157
|
+
* @param {object} [args.convertOptions] passed to convertFile (decoders, tools)
|
|
158
|
+
* @param {Function} [args.convert] convertFile replacement (tests)
|
|
159
|
+
* @param {number} [args.maxBundleBytes] defaults to 1 GiB
|
|
160
|
+
* @param {Date} [args.now]
|
|
161
|
+
* @returns {Promise<{manifest:object, results:Array<{rel:string, verdict:string, codes:string[], reasons:string[], fix:string|null, ids:string[], notes?:Array<{code:'metadata_dropped'|'metadata_truncated', fields:string[]}>}>, bytes:number}>}
|
|
162
|
+
* @throws {BundleTooBigError} "This folder is too big; link a smaller folder"
|
|
163
|
+
*/
|
|
164
|
+
async function buildBundle(args) {
|
|
165
|
+
const { scan, outDir, appVersion } = args;
|
|
166
|
+
const leaks = makeLeakCheck({ root: scan.root });
|
|
167
|
+
const label = cleanLabel(args.folderLabel, leaks);
|
|
168
|
+
const state = {
|
|
169
|
+
args,
|
|
170
|
+
leaks,
|
|
171
|
+
label,
|
|
172
|
+
labelDropped: typeof args.folderLabel === 'string' && args.folderLabel.length > 0 && label === '',
|
|
173
|
+
maxBytes: args.maxBundleBytes || MAX_BUNDLE_BYTES,
|
|
174
|
+
captures: [],
|
|
175
|
+
skipped: [],
|
|
176
|
+
results: [],
|
|
177
|
+
bytes: 0,
|
|
178
|
+
};
|
|
179
|
+
fs.mkdirSync(path.join(outDir, 'images'), { recursive: true });
|
|
180
|
+
for (const refused of scan.refused || []) {
|
|
181
|
+
skipFile(state, refused);
|
|
182
|
+
state.results.push({ rel: refused.rel, verdict: 'failed', codes: refused.codes, reasons: refused.reasons, fix: refused.fix, ids: [] });
|
|
183
|
+
}
|
|
184
|
+
try {
|
|
185
|
+
for (const file of scan.files) await addFile(state, file);
|
|
186
|
+
const manifest = {
|
|
187
|
+
$schema: SCF_SCHEMA_URL,
|
|
188
|
+
scf: '1.0',
|
|
189
|
+
source: { kind: SOURCE_KIND, platform: 'other', tool: { name: 'scry-sync', version: APP_VERSION_RE.test(String(appVersion)) ? appVersion : 'unknown' } },
|
|
190
|
+
createdAt: (args.now || new Date()).toISOString(),
|
|
191
|
+
counts: { declared: scan.files.length + (scan.refused || []).length, captured: state.captures.length, skipped: state.skipped },
|
|
192
|
+
captures: state.captures,
|
|
193
|
+
};
|
|
194
|
+
const json = JSON.stringify(manifest, null, 2) + '\n';
|
|
195
|
+
countBytes(state, Buffer.byteLength(json));
|
|
196
|
+
fs.writeFileSync(path.join(outDir, 'scf.json'), json);
|
|
197
|
+
return { manifest, results: state.results, bytes: state.bytes };
|
|
198
|
+
} catch (error) {
|
|
199
|
+
if (error instanceof BundleTooBigError || error instanceof TooManyCapturesError) fs.rmSync(path.join(outDir, 'images'), { recursive: true, force: true });
|
|
200
|
+
throw error;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function countBytes(state, n) {
|
|
205
|
+
state.bytes += n;
|
|
206
|
+
if (state.bytes > state.maxBytes) throw new BundleTooBigError();
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* A failed file goes into counts.skipped with its plain reason. The reasons are our own sentences; if one ever
|
|
211
|
+
* carried a location (a tool's message, say) it is replaced by the code's standard sentence, never edited.
|
|
212
|
+
*/
|
|
213
|
+
function skipFile(state, result) {
|
|
214
|
+
const code = result.codes[0];
|
|
215
|
+
const text = result.reasons.join(' ');
|
|
216
|
+
const standard = FAILED[code] ? FAILED[code][0] : 'The file could not be converted.';
|
|
217
|
+
const detail = state.leaks(text) ? standard : text;
|
|
218
|
+
state.skipped.push({ id: `file-${state.skipped.length + 1}`, reason: SKIP_REASON[code] || 'error', detail });
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** The id of every picture of a converted file, or null when the file's name cannot be an id (checked before anything is written). */
|
|
222
|
+
function idsFor(state, file, pictures) {
|
|
223
|
+
try {
|
|
224
|
+
return pictures.map((picture) => pictureId(state.args.folderUuid, file.rel, picture.suffix ? Number(picture.suffix.slice(2)) : undefined));
|
|
225
|
+
} catch {
|
|
226
|
+
return null;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** The `x-scry-sync` block of a converted file; a `metadata_dropped` note also goes on the file's result row. */
|
|
231
|
+
function vendorBlock(state, result, entry, fileNameChanged) {
|
|
232
|
+
const { meta, dropped: droppedMeta } = cleanMeta(result.xmp || {}, state.leaks);
|
|
233
|
+
const dropped = fileNameChanged ? [...droppedMeta, 'fileName'] : droppedMeta;
|
|
234
|
+
// F122: a kept field that importXmp cut to its cap (title 200, description 2000, ...) is named, never its value.
|
|
235
|
+
const truncated = (result.xmpTruncated || []).filter((field) => field in meta);
|
|
236
|
+
const notes = metadataNotes(state.labelDropped ? [...dropped, 'folderLabel'] : dropped, truncated);
|
|
237
|
+
const block = { ...meta, origin: originBlock({ format: result.format, verdict: result.verdict, appVersion: state.args.appVersion }) };
|
|
238
|
+
if (notes) {
|
|
239
|
+
block.notes = notes;
|
|
240
|
+
entry.notes = notes;
|
|
241
|
+
}
|
|
242
|
+
return { meta, block };
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** Convert one file and add a capture per picture (or a skip). */
|
|
246
|
+
async function addFile(state, file) {
|
|
247
|
+
const { args } = state;
|
|
248
|
+
let result;
|
|
249
|
+
try {
|
|
250
|
+
result = oddFileName(file.rel) ? failed(null, 'odd_name') : await (args.convert || convertFile)(file.abs, { ...(args.convertOptions || {}), root: args.scan.root });
|
|
251
|
+
} catch {
|
|
252
|
+
result = failed(null, 'unreadable');
|
|
253
|
+
}
|
|
254
|
+
const ids = result.verdict === 'failed' ? [] : idsFor(state, file, result.pictures);
|
|
255
|
+
if (!ids) result = failed(null, 'odd_name');
|
|
256
|
+
const entry = { rel: file.rel, verdict: result.verdict, codes: result.codes, reasons: result.reasons, fix: result.fix, ids: [] };
|
|
257
|
+
state.results.push(entry);
|
|
258
|
+
if (result.verdict === 'failed') {
|
|
259
|
+
skipFile(state, result);
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
const { stem, changed } = safeStem(file.rel, state.leaks, ids[0]);
|
|
263
|
+
const { meta, block } = vendorBlock(state, result, entry, changed);
|
|
264
|
+
const title = state.label ? [state.label, stem] : [stem];
|
|
265
|
+
for (const [index, picture] of result.pictures.entries()) {
|
|
266
|
+
if (state.captures.length >= MAX_BUNDLE_CAPTURES) throw new TooManyCapturesError();
|
|
267
|
+
const page = picture.suffix ? Number(picture.suffix.slice(2)) : undefined;
|
|
268
|
+
const id = ids[index];
|
|
269
|
+
const name = `${sha256Hex(id)}.${EXT_OF_FAMILY[picture.family]}`;
|
|
270
|
+
countBytes(state, picture.bytes.length);
|
|
271
|
+
fs.writeFileSync(path.join(args.outDir, 'images', name), picture.bytes);
|
|
272
|
+
const capture = {
|
|
273
|
+
id,
|
|
274
|
+
image: `images/${name}`,
|
|
275
|
+
kind: 'doc-image',
|
|
276
|
+
title,
|
|
277
|
+
capture: { method: 'design-export', size: { width: picture.width, height: picture.height } },
|
|
278
|
+
[VENDOR_KEY]: block,
|
|
279
|
+
};
|
|
280
|
+
if (page) capture.name = `Page ${page}`;
|
|
281
|
+
if (meta.keywords) capture.tags = meta.keywords;
|
|
282
|
+
state.captures.push(capture);
|
|
283
|
+
entry.ids.push(id);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
module.exports = {
|
|
288
|
+
SOURCE_KIND,
|
|
289
|
+
VENDOR_KEY,
|
|
290
|
+
MAX_BUNDLE_BYTES,
|
|
291
|
+
MAX_BUNDLE_CAPTURES,
|
|
292
|
+
TOO_BIG_MESSAGE,
|
|
293
|
+
TOO_MANY_MESSAGE,
|
|
294
|
+
BundleTooBigError,
|
|
295
|
+
TooManyCapturesError,
|
|
296
|
+
originBlock,
|
|
297
|
+
buildBundle,
|
|
298
|
+
};
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* convertFile(path, opts): one file in the synced folder -> its pictures and an honest verdict (guarantee G3).
|
|
3
|
+
*
|
|
4
|
+
* { format, pictures: [{ suffix, bytes, family, width, height }], verdict, codes, reasons, fix }
|
|
5
|
+
*
|
|
6
|
+
* `suffix` is '' for a single picture and `#p<N>` for each PDF/AI page (always, even a one-page PDF, so adding a
|
|
7
|
+
* page later never changes the first page's id). Pictures are sRGB PNG/JPEG, <= 2048 px, <= 4 MB, no metadata.
|
|
8
|
+
* The verdict is `faithful`, `approximate` (with reasons) or `failed` (no pictures, a reason and the fix).
|
|
9
|
+
* convertFile never throws for a bad file: whatever happens, the file gets a verdict.
|
|
10
|
+
*/
|
|
11
|
+
const fs = require('fs');
|
|
12
|
+
const path = require('path');
|
|
13
|
+
const { measure } = require('../importConvert.js');
|
|
14
|
+
const legacy = require('../importConvert.js');
|
|
15
|
+
const { stripMetadata } = require('../importStrip.js');
|
|
16
|
+
const { readEmbeddedPacket, parseAllowListed, mergeXmp } = require('../importXmp.js');
|
|
17
|
+
const { readSidecarPacket } = require('../importScan.js');
|
|
18
|
+
const { sharp, tooLarge, isSrgbProfile, encodeForScry, readOptions, MAX_RASTER_BYTES, MAX_RASTER_DIMENSION } = require('./raster.js');
|
|
19
|
+
const { readPsdComposite } = require('./psd.js');
|
|
20
|
+
const { renderPdfPages, hasPdfHeader } = require('./pdf.js');
|
|
21
|
+
const { failed, finish } = require('./verdicts.js');
|
|
22
|
+
const { openNoFollow, readNoFollow } = require('./safeopen.js');
|
|
23
|
+
const { confinedRealPath } = require('../importScan.js');
|
|
24
|
+
|
|
25
|
+
/** Extension -> format (the `convertedFrom` value in the origin block). */
|
|
26
|
+
const FORMATS = Object.freeze({
|
|
27
|
+
'.png': 'png',
|
|
28
|
+
'.jpg': 'jpeg',
|
|
29
|
+
'.jpeg': 'jpeg',
|
|
30
|
+
'.webp': 'webp',
|
|
31
|
+
'.tif': 'tiff',
|
|
32
|
+
'.tiff': 'tiff',
|
|
33
|
+
'.psd': 'psd',
|
|
34
|
+
'.psb': 'psb',
|
|
35
|
+
'.pdf': 'pdf',
|
|
36
|
+
'.ai': 'ai',
|
|
37
|
+
'.heic': 'heic',
|
|
38
|
+
'.heif': 'heif',
|
|
39
|
+
'.indd': 'indd',
|
|
40
|
+
});
|
|
41
|
+
const PASS_THROUGH = new Set(['png', 'jpeg', 'webp']);
|
|
42
|
+
/** Bigger source files are refused before reading (a 200 MP picture is far below this). */
|
|
43
|
+
const MAX_SOURCE_BYTES = 2 * 1024 * 1024 * 1024;
|
|
44
|
+
|
|
45
|
+
function formatOf(file) {
|
|
46
|
+
return FORMATS[path.extname(file).toLowerCase()] || null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function encodeOne(open) {
|
|
50
|
+
const out = await encodeForScry(open);
|
|
51
|
+
return out ? { suffix: '', ...out } : null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** PNG/JPEG/WebP/TIFF through sharp; an untouched copy (metadata stripped) when it is already sRGB and in bounds. */
|
|
55
|
+
async function convertRaster(file, format) {
|
|
56
|
+
const meta = await sharp(file, readOptions()).metadata();
|
|
57
|
+
if (tooLarge(meta.width, meta.height)) return failed(format, 'too_large', `${meta.width}x${meta.height}`);
|
|
58
|
+
const codes = [];
|
|
59
|
+
if ((meta.pages || 1) > 1) codes.push('extra_frames');
|
|
60
|
+
if (meta.space === 'cmyk' && !meta.icc) codes.push('cmyk_no_icc');
|
|
61
|
+
if (PASS_THROUGH.has(format) && meta.space !== 'cmyk' && isSrgbProfile(meta.icc) && (meta.orientation || 1) === 1) {
|
|
62
|
+
const raw = readNoFollow(file);
|
|
63
|
+
const found = measure(raw);
|
|
64
|
+
const clean = found ? stripMetadata(raw, found.family) : null;
|
|
65
|
+
const dims = clean ? measure(clean) : null;
|
|
66
|
+
const fits = dims && clean.length <= MAX_RASTER_BYTES && dims.width <= MAX_RASTER_DIMENSION && dims.height <= MAX_RASTER_DIMENSION;
|
|
67
|
+
if (fits && !legacy.outputProblem(clean, dims)) return finish(format, [{ suffix: '', bytes: clean, ...dims }], codes);
|
|
68
|
+
}
|
|
69
|
+
const picture = await encodeOne(() => sharp(file, readOptions()).rotate());
|
|
70
|
+
return picture ? finish(format, [picture], codes) : failed(format, 'output_too_big');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function convertPsd(file, format) {
|
|
74
|
+
const composite = readPsdComposite(file);
|
|
75
|
+
if (composite.failure) return failed(format, composite.failure, composite.failure === 'too_large' ? `${composite.width}x${composite.height}` : undefined);
|
|
76
|
+
const picture = await encodeOne(() => sharp(composite.tiff));
|
|
77
|
+
return picture ? finish(format, [picture], composite.codes) : failed(format, 'output_too_big');
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
class PageTooBig extends Error {
|
|
81
|
+
constructor(page) {
|
|
82
|
+
super('page too big');
|
|
83
|
+
this.page = page;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function convertPdf(file, format) {
|
|
88
|
+
const bytes = readNoFollow(file);
|
|
89
|
+
if (format === 'ai' && !hasPdfHeader(bytes)) return failed(format, 'ai_no_pdf');
|
|
90
|
+
const pictures = [];
|
|
91
|
+
// Each page is encoded and released before the next is drawn (see pdf.js); only the small encoded pictures are kept.
|
|
92
|
+
let rendered;
|
|
93
|
+
try {
|
|
94
|
+
rendered = await renderPdfPages(bytes, async (page) => {
|
|
95
|
+
const raw = { raw: { width: page.width, height: page.height, channels: 3 } };
|
|
96
|
+
const picture = await encodeForScry(() => sharp(page.rgb, raw));
|
|
97
|
+
if (!picture) throw new PageTooBig(page.page);
|
|
98
|
+
pictures.push({ suffix: `#p${page.page}`, ...picture });
|
|
99
|
+
});
|
|
100
|
+
} catch (error) {
|
|
101
|
+
if (error instanceof PageTooBig) return failed(format, 'output_too_big', `page ${error.page}`);
|
|
102
|
+
throw error;
|
|
103
|
+
}
|
|
104
|
+
if (rendered.failure) return failed(format, rendered.failure);
|
|
105
|
+
return finish(format, pictures, rendered.codes);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* HEIC/HEIF. The decoder is the OS's, plugged in by the app (`opts.decoders.heic(path) -> Promise<Buffer>` of a
|
|
110
|
+
* PNG/JPEG/TIFF sharp can read: Windows WIC, macOS ImageIO). Without one, the command-line tools `scry import`
|
|
111
|
+
* uses (sips, ImageMagick) are tried, whose colours are not converted to sRGB (approximate).
|
|
112
|
+
*/
|
|
113
|
+
async function convertHeic(file, format, opts) {
|
|
114
|
+
const decode = opts.decoders && opts.decoders.heic;
|
|
115
|
+
if (decode) {
|
|
116
|
+
let decoded;
|
|
117
|
+
try {
|
|
118
|
+
decoded = await decode(file);
|
|
119
|
+
} catch {
|
|
120
|
+
return failed(format, 'unreadable', 'the HEIC decoder could not read it');
|
|
121
|
+
}
|
|
122
|
+
const meta = await sharp(decoded, readOptions()).metadata();
|
|
123
|
+
if (tooLarge(meta.width, meta.height)) return failed(format, 'too_large', `${meta.width}x${meta.height}`);
|
|
124
|
+
const picture = await encodeOne(() => sharp(decoded, readOptions()).rotate());
|
|
125
|
+
return picture ? finish(format, [picture], []) : failed(format, 'output_too_big');
|
|
126
|
+
}
|
|
127
|
+
const tools = opts.tools || legacy.detectTools();
|
|
128
|
+
if (tools.filter((t) => t !== 'pdftoppm').length === 0) return failed(format, 'heic_no_decoder');
|
|
129
|
+
const result = await legacy.convertFile(file, '.heic', { tools, run: opts.run });
|
|
130
|
+
if (!result.ok) return failed(format, 'unreadable', result.detail);
|
|
131
|
+
const { bytes, family, width, height } = result;
|
|
132
|
+
return finish(format, [{ suffix: '', bytes, family, width, height }], ['colour_unmanaged']);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Allow-listed XMP (title, description, keywords, rating, label, creator) from the file and its .xmp sidecar. */
|
|
136
|
+
function readXmp(file, root) {
|
|
137
|
+
let embedded = {};
|
|
138
|
+
const embeddedCut = new Set();
|
|
139
|
+
const fd = openNoFollow(file);
|
|
140
|
+
const size = fs.fstatSync(fd).size;
|
|
141
|
+
try {
|
|
142
|
+
const packet = readEmbeddedPacket(fd, size);
|
|
143
|
+
if (packet) embedded = parseAllowListed(packet, { cut: embeddedCut });
|
|
144
|
+
} finally {
|
|
145
|
+
fs.closeSync(fd);
|
|
146
|
+
}
|
|
147
|
+
const sidecarCut = new Set();
|
|
148
|
+
const sidecar = root ? readSidecarPacket(file, root) : {};
|
|
149
|
+
const fromSidecar = sidecar.packet ? parseAllowListed(sidecar.packet, { cut: sidecarCut }) : {};
|
|
150
|
+
const xmp = mergeXmp(embedded, fromSidecar);
|
|
151
|
+
// A field was cut when the copy that won the merge (the sidecar's, else the file's) was cut.
|
|
152
|
+
const cut = Object.keys(xmp).filter((field) => (field in fromSidecar ? sidecarCut : embeddedCut).has(field));
|
|
153
|
+
return { xmp, cut };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async function dispatch(file, format, opts) {
|
|
157
|
+
if (format === 'indd') return failed(format, 'indd_needs_pdf');
|
|
158
|
+
if (format === 'psd' || format === 'psb') return convertPsd(file, format);
|
|
159
|
+
if (format === 'pdf' || format === 'ai') return convertPdf(file, format);
|
|
160
|
+
if (format === 'heic' || format === 'heif') return convertHeic(file, format, opts);
|
|
161
|
+
return convertRaster(file, format);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** What identifies the file between two looks at it (a swap or an edit changes it). */
|
|
165
|
+
function signatureOf(stat) {
|
|
166
|
+
return `${stat.dev}:${stat.ino}:${stat.size}:${stat.mtimeMs}`;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* The checks made at READ time, not only at scan time (the folder can change between the two): the path is a plain
|
|
171
|
+
* file (not a link) and its real path is inside the real root (`root` is required: convertFile refuses to run
|
|
172
|
+
* without it). Returns a failed code or the file's signature.
|
|
173
|
+
*/
|
|
174
|
+
function checkAtRead(file, root) {
|
|
175
|
+
let stat;
|
|
176
|
+
try {
|
|
177
|
+
stat = fs.lstatSync(file);
|
|
178
|
+
} catch {
|
|
179
|
+
return { code: 'unreadable' };
|
|
180
|
+
}
|
|
181
|
+
if (stat.isSymbolicLink()) return { code: 'not_followed' };
|
|
182
|
+
if (!stat.isFile()) return { code: 'unreadable' };
|
|
183
|
+
let realRoot;
|
|
184
|
+
try {
|
|
185
|
+
realRoot = fs.realpathSync(root);
|
|
186
|
+
} catch {
|
|
187
|
+
return { code: 'outside_folder' };
|
|
188
|
+
}
|
|
189
|
+
if (!confinedRealPath(file, realRoot)) return { code: 'outside_folder' };
|
|
190
|
+
return { stat, signature: signatureOf(stat) };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
async function convertChecked(file, format, opts) {
|
|
194
|
+
const before = checkAtRead(file, opts.root);
|
|
195
|
+
if (before.code) return failed(format, before.code);
|
|
196
|
+
if (before.stat.size === 0) return failed(format, 'empty');
|
|
197
|
+
if (before.stat.size > MAX_SOURCE_BYTES) return failed(format, 'too_large', 'file over 2 GB');
|
|
198
|
+
const result = await dispatch(file, format, opts);
|
|
199
|
+
// Looked at again after the read: a file swapped for a link (or edited) while it was read is not trusted.
|
|
200
|
+
const after = checkAtRead(file, opts.root);
|
|
201
|
+
if (after.code) return failed(format, after.code);
|
|
202
|
+
if (after.signature !== before.signature) return failed(format, 'changed_while_reading');
|
|
203
|
+
return result;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* @param {string} file absolute path
|
|
208
|
+
* @param {{root:string, decoders?:{heic?:(file:string)=>Promise<Buffer>}, tools?:string[], run?:Function, metadata?:boolean}} [opts]
|
|
209
|
+
* `root` (required): the synced folder; every read is confined to it (a file or a subfolder swapped for a link to
|
|
210
|
+
* somewhere outside fails as `outside_folder`), and the .xmp sidecar is looked up inside it. `metadata: false` skips XMP.
|
|
211
|
+
* @throws {TypeError} when `root` is missing (the message names the option, never the file)
|
|
212
|
+
* @returns {Promise<{format:string|null, pictures:Array, verdict:'faithful'|'approximate'|'failed', codes:string[], reasons:string[], fix:string|null, xmp?:object, xmpTruncated?:string[]}>}
|
|
213
|
+
*/
|
|
214
|
+
async function convertFile(file, opts = {}) {
|
|
215
|
+
if (!opts || typeof opts.root !== 'string' || opts.root.length === 0) {
|
|
216
|
+
throw new TypeError('convertFile needs opts.root (the synced folder) so that every read stays inside it');
|
|
217
|
+
}
|
|
218
|
+
const format = formatOf(file);
|
|
219
|
+
if (!format) return failed(null, 'unsupported_type');
|
|
220
|
+
let result;
|
|
221
|
+
try {
|
|
222
|
+
result = await convertChecked(file, format, opts);
|
|
223
|
+
} catch (error) {
|
|
224
|
+
const tooBig = /pixel limit|exceeds|too large/i.test(String(error && error.message));
|
|
225
|
+
result = failed(format, tooBig ? 'too_large' : 'unreadable');
|
|
226
|
+
}
|
|
227
|
+
if (result.verdict !== 'failed' && opts.metadata !== false) {
|
|
228
|
+
try {
|
|
229
|
+
const { xmp, cut } = readXmp(file, opts.root);
|
|
230
|
+
if (Object.keys(xmp).length > 0) result.xmp = xmp;
|
|
231
|
+
if (cut.length > 0) result.xmpTruncated = cut;
|
|
232
|
+
} catch {
|
|
233
|
+
// metadata is optional; the picture stands without it
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
return result;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
module.exports = { FORMATS, formatOf, convertFile };
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stable picture ids for Scry Sync (founder decision D2: identify a picture by its place in the folder,
|
|
3
|
+
* not by its content, so editing a file keeps its id and the Figma links, issues and history on it).
|
|
4
|
+
*
|
|
5
|
+
* pictureId(folderUuid, relativePath) = sha256(folderUuid + normalisedPath), lower-case hex
|
|
6
|
+
* pictureId(folderUuid, relativePath, page) = the same + `#p<page>` (PDF/AI pages, 1-based)
|
|
7
|
+
*
|
|
8
|
+
* Normalisation of the relative path, so the same file gives the same id on Windows and on a Mac:
|
|
9
|
+
* - separators: every `\` becomes `/` (a Windows path and a Mac path of the same file are equal);
|
|
10
|
+
* - leading `./` and `/`, repeated `/` and `.` segments are removed; `..` is refused (not inside the folder);
|
|
11
|
+
* - Unicode NFC (macOS hands out decomposed NFD names, Windows composed NFC ones);
|
|
12
|
+
* - case is PRESERVED: `Home.psd` and `home.psd` are two ids (a rename that only changes case is a new id).
|
|
13
|
+
*
|
|
14
|
+
* The folder UUID is minted once per synced folder by the app and kept locally; it is a fixed-length UUID, so
|
|
15
|
+
* plain concatenation is unambiguous. The id is a hash: no path, file name or user name can be read from it.
|
|
16
|
+
*/
|
|
17
|
+
const crypto = require('crypto');
|
|
18
|
+
|
|
19
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
20
|
+
|
|
21
|
+
class PictureIdError extends Error {
|
|
22
|
+
constructor(message) {
|
|
23
|
+
super(message);
|
|
24
|
+
this.name = 'PictureIdError';
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** The relative path in the one form every OS agrees on (see the module comment). */
|
|
29
|
+
function normaliseRelativePath(relativePath) {
|
|
30
|
+
if (typeof relativePath !== 'string' || relativePath.length === 0) throw new PictureIdError('relative path is empty');
|
|
31
|
+
const segments = relativePath.normalize('NFC').replace(/\\/g, '/').split('/');
|
|
32
|
+
const kept = [];
|
|
33
|
+
for (const segment of segments) {
|
|
34
|
+
if (segment === '' || segment === '.') continue;
|
|
35
|
+
if (segment === '..') throw new PictureIdError('relative path leaves the folder');
|
|
36
|
+
kept.push(segment);
|
|
37
|
+
}
|
|
38
|
+
if (kept.length === 0) throw new PictureIdError('relative path is empty');
|
|
39
|
+
if (/^[A-Za-z]:$/.test(kept[0])) throw new PictureIdError('expected a path relative to the synced folder, got a drive path');
|
|
40
|
+
return kept.join('/');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* @param {string} folderUuid the synced folder's UUID
|
|
45
|
+
* @param {string} relativePath path of the file inside the folder, with `/` or `\`
|
|
46
|
+
* @param {number} [page] 1-based page for PDF/AI pictures
|
|
47
|
+
* @returns {string} 64 hex chars, plus `#p<page>` when a page is given
|
|
48
|
+
*/
|
|
49
|
+
function pictureId(folderUuid, relativePath, page) {
|
|
50
|
+
if (typeof folderUuid !== 'string' || !UUID_RE.test(folderUuid)) throw new PictureIdError('folder id must be a UUID');
|
|
51
|
+
const hex = crypto.createHash('sha256').update(folderUuid.toLowerCase() + normaliseRelativePath(relativePath), 'utf8').digest('hex');
|
|
52
|
+
if (page === undefined || page === null) return hex;
|
|
53
|
+
if (!Number.isInteger(page) || page < 1) throw new PictureIdError('page must be a positive integer');
|
|
54
|
+
return `${hex}#p${page}`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
module.exports = { PictureIdError, normaliseRelativePath, pictureId };
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scry Sync converter: the library the desktop app (scry-sync, Electron main process) uses to turn a synced
|
|
3
|
+
* folder into an SCF bundle. CommonJS, Node >= 20.9, no network, no child processes unless the HEIC fallback is used.
|
|
4
|
+
*
|
|
5
|
+
* const converter = require('@scrymore/scry-deployer/lib/converter');
|
|
6
|
+
* const scan = converter.scanFolder(folder); // files with kind and size; refused ones
|
|
7
|
+
* const one = await converter.convertFile(file, { root: scan.root, decoders }); // pictures + verdict + reasons + fix (root required)
|
|
8
|
+
* const { manifest } = await converter.buildBundle({ folderUuid, scan, outDir, appVersion });
|
|
9
|
+
* converter.pictureId(folderUuid, 'Sub/Home.psd') // stable id (see ids.js)
|
|
10
|
+
*
|
|
11
|
+
* `scry import` (the CLI) is unchanged and does not use this module.
|
|
12
|
+
*/
|
|
13
|
+
const { pictureId, normaliseRelativePath, PictureIdError } = require('./ids.js');
|
|
14
|
+
const { scanFolder, ScanError, kindOf, isTooBroad } = require('./scan.js');
|
|
15
|
+
const { convertFile, FORMATS } = require('./convert.js');
|
|
16
|
+
const { buildBundle, originBlock, BundleTooBigError, TooManyCapturesError, TOO_BIG_MESSAGE, TOO_MANY_MESSAGE, SOURCE_KIND, VENDOR_KEY, MAX_BUNDLE_BYTES, MAX_BUNDLE_CAPTURES } = require('./bundle.js');
|
|
17
|
+
const { VERDICTS, APPROXIMATE, FAILED } = require('./verdicts.js');
|
|
18
|
+
const { MAX_SOURCE_EDGE, MAX_SOURCE_PIXELS, MAX_RASTER_BYTES, MAX_RASTER_DIMENSION } = require('./raster.js');
|
|
19
|
+
|
|
20
|
+
module.exports = {
|
|
21
|
+
scanFolder,
|
|
22
|
+
convertFile,
|
|
23
|
+
buildBundle,
|
|
24
|
+
pictureId,
|
|
25
|
+
normaliseRelativePath,
|
|
26
|
+
originBlock,
|
|
27
|
+
kindOf,
|
|
28
|
+
isTooBroad,
|
|
29
|
+
FORMATS,
|
|
30
|
+
VERDICTS,
|
|
31
|
+
APPROXIMATE,
|
|
32
|
+
FAILED,
|
|
33
|
+
SOURCE_KIND,
|
|
34
|
+
VENDOR_KEY,
|
|
35
|
+
LIMITS: Object.freeze({ MAX_SOURCE_EDGE, MAX_SOURCE_PIXELS, MAX_RASTER_BYTES, MAX_RASTER_DIMENSION, MAX_BUNDLE_BYTES, MAX_BUNDLE_CAPTURES }),
|
|
36
|
+
TOO_BIG_MESSAGE,
|
|
37
|
+
TOO_MANY_MESSAGE,
|
|
38
|
+
BundleTooBigError,
|
|
39
|
+
TooManyCapturesError,
|
|
40
|
+
PictureIdError,
|
|
41
|
+
ScanError,
|
|
42
|
+
};
|