sygal-cli 0.3.1 → 0.3.5
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/CHANGELOG.md +15 -0
- package/dist/index.js +96 -15
- package/package.json +4 -4
package/CHANGELOG.md
CHANGED
|
@@ -9,6 +9,21 @@ partagent toujours le même numéro de version, publiés ensemble, même si un
|
|
|
9
9
|
seul a réellement changé de contenu — même principe que l'app desktop.
|
|
10
10
|
Détails du process : `CLI_RELEASE_GUIDE.md` à la racine du repo.
|
|
11
11
|
|
|
12
|
+
## 0.3.1
|
|
13
|
+
|
|
14
|
+
- **Corrige la 0.3.0**, publiée avec un bundle `sygal-cli` (dist/index.js)
|
|
15
|
+
périmé — construit avant les changements ci-dessous, donc `.rtf` restait
|
|
16
|
+
"Extension non supportée" et aucun des correctifs OCR n'était réellement
|
|
17
|
+
présent malgré le numéro de version. Ajout d'un hook `prepublishOnly`
|
|
18
|
+
(rebuild automatique avant toute publication) pour que ça ne puisse plus
|
|
19
|
+
se reproduire. **Si vous avez installé la 0.3.0, mettez à jour vers la
|
|
20
|
+
0.3.1.**
|
|
21
|
+
- Nouveau : support des formats **AVIF** et **RTF** (texte brut, tableaux
|
|
22
|
+
RTF rendus lisibles ligne par ligne).
|
|
23
|
+
- Fix : messages d'erreur clairs pour HEIC/HEIF (HEVC non pris en charge),
|
|
24
|
+
AVIF "séquence"/rafale, et WebP envoyé au fournisseur NVIDIA — au lieu
|
|
25
|
+
d'erreurs génériques ou d'un faux "erreur réseau".
|
|
26
|
+
|
|
12
27
|
## 0.2.1
|
|
13
28
|
|
|
14
29
|
- Notice de mise à jour après chaque conversion (terminal interactif
|
package/dist/index.js
CHANGED
|
@@ -71,6 +71,10 @@ var SUPPORTED_EXTENSIONS = {
|
|
|
71
71
|
heif: "image/heif",
|
|
72
72
|
avif: "image/avif"
|
|
73
73
|
};
|
|
74
|
+
var PACKAGE_DIR_EXTENSIONS = /* @__PURE__ */ new Set(["epub"]);
|
|
75
|
+
function isPackageDirExtension(extension) {
|
|
76
|
+
return PACKAGE_DIR_EXTENSIONS.has(extension.toLowerCase().replace(/^\./, ""));
|
|
77
|
+
}
|
|
74
78
|
function mimeForExtension(extension) {
|
|
75
79
|
return SUPPORTED_EXTENSIONS[extension.toLowerCase()] ?? null;
|
|
76
80
|
}
|
|
@@ -121,10 +125,16 @@ var OCR_MODELS = {
|
|
|
121
125
|
openai: "gpt-4o",
|
|
122
126
|
mistral: "mistral-ocr-latest",
|
|
123
127
|
claude: "claude-sonnet-4-6",
|
|
124
|
-
// Bascule
|
|
125
|
-
// remplace
|
|
126
|
-
//
|
|
127
|
-
|
|
128
|
+
// Bascule 27/07/2026 (banc comparatif sur images réelles, validation Cyrill) :
|
|
129
|
+
// nemotron-nano-12b-v2-vl remplace llama-3.1-nemotron-nano-vl-8b-v1. Sur une
|
|
130
|
+
// capture de dossier de 22 lignes, le 8b s'arrêtait au premier en-tête (« Nom »,
|
|
131
|
+
// 1 token) 3 fois sur 3 ; le 12b la transcrit intégralement 3 fois sur 3, accents
|
|
132
|
+
// préservés. Il respecte aussi la consigne de langue sur une image sans texte
|
|
133
|
+
// (description en français, là où le 8b répondait en anglais), sans confabuler.
|
|
134
|
+
// Entraîné sur DocLayNet / PubTables-1M / PDF CommonCrawl — OCRBench 85,6.
|
|
135
|
+
// Précédemment : llama-3.2-11b-vision-instruct, écarté le 18/07/2026 (5/5
|
|
136
|
+
// confabulations sur un portrait sans texte).
|
|
137
|
+
nvidia: "nvidia/nemotron-nano-12b-v2-vl",
|
|
128
138
|
gemini: "gemini-2.0-flash"
|
|
129
139
|
};
|
|
130
140
|
var MISTRAL_TRANSLATE_MODEL = "mistral-large-latest";
|
|
@@ -410,7 +420,7 @@ var LOCALE_LANGUAGE = {
|
|
|
410
420
|
es: "Spanish"
|
|
411
421
|
};
|
|
412
422
|
function buildOcrPrompt(labelLanguage = "French") {
|
|
413
|
-
return `Transcribe ALL visible text in this image verbatim as clean Markdown, preserving the document layout: headings, lists and tables exactly as shown. Transcribe EVERY line, row and table cell faithfully \u2014 never summarize, skip, merge, reorder, or invent rows, values, or text. If a cell is empty, leave it empty. Keep the document text in its ORIGINAL language exactly as written \u2014 do not translate it. Do NOT invent a table of contents, section titles, or any heading/structure that is not visually present in the document. If you must add a label of your own that is not in the document, write it in ${labelLanguage}. While transcribing, do NOT describe the scene, the people, or any visual elements that are not text. ONLY IF the image contains no readable text at all: instead of an empty document, describe the image in ${labelLanguage} as clean Markdown \u2014 one short paragraph covering the subject, the setting and the dominant colors, factual and concise, without inventing any text that is not visible. When describing, NEVER invent or guess names, ages, occupations, employers, biographies, locations, dates, or any information about people or organizations. Describe only what is physically visible in the image; if something is not visible, do not mention it. Output only the Markdown.`;
|
|
423
|
+
return `Transcribe ALL visible text in this image verbatim as clean Markdown, preserving the document layout: headings, lists and tables exactly as shown. Render every table as a GitHub-flavored Markdown table using pipes (| \u2026 | \u2026 |) with a header separator row \u2014 NEVER output LaTeX, never use \\begin{tabular}, \\multicolumn or any TeX command. Transcribe EVERY line, row and table cell faithfully \u2014 never summarize, skip, merge, reorder, or invent rows, values, or text. If a cell is empty, leave it empty. Keep the document text in its ORIGINAL language exactly as written \u2014 do not translate it. Do NOT invent a table of contents, section titles, or any heading/structure that is not visually present in the document. If you must add a label of your own that is not in the document, write it in ${labelLanguage}. While transcribing, do NOT describe the scene, the people, or any visual elements that are not text. ONLY IF the image contains no readable text at all: instead of an empty document, describe the image in ${labelLanguage} as clean Markdown \u2014 one short paragraph covering the subject, the setting and the dominant colors, factual and concise, without inventing any text that is not visible. When describing, NEVER invent or guess names, ages, occupations, employers, biographies, locations, dates, or any information about people or organizations. Describe only what is physically visible in the image; if something is not visible, do not mention it. Output only the Markdown.`;
|
|
414
424
|
}
|
|
415
425
|
function buildTranslatePrompt(targetLocale) {
|
|
416
426
|
const language = LOCALE_LANGUAGE[targetLocale] ?? "English";
|
|
@@ -668,6 +678,30 @@ var mistralProvider = {
|
|
|
668
678
|
}
|
|
669
679
|
};
|
|
670
680
|
|
|
681
|
+
// ../core/src/ocr/latex-tables.ts
|
|
682
|
+
var TABULAR_RE = /\\begin\{tabular\}(?:\{[^}]*\})?([\s\S]*?)\\end\{tabular\}/g;
|
|
683
|
+
function cleanCell(cell) {
|
|
684
|
+
return cell.replace(/\\multicolumn\{\d+\}\{[^}]*\}\{([\s\S]*?)\}/g, "$1").replace(/\\multirow\{\d+\}\{[^}]*\}\{([\s\S]*?)\}/g, "$1").replace(/\\textbf\{([\s\S]*?)\}/g, "**$1**").replace(/\\textit\{([\s\S]*?)\}/g, "*$1*").replace(/\\(?:hline|toprule|midrule|bottomrule)\b/g, "").replace(/\\\\/g, "").replace(/\|/g, "\\|").replace(/\s+/g, " ").trim();
|
|
685
|
+
}
|
|
686
|
+
function tabularToMarkdown(body) {
|
|
687
|
+
const rows = body.split(/\\\\/).map((row) => row.trim()).filter((row) => row.replace(/\\(?:hline|toprule|midrule|bottomrule)\b/g, "").trim().length > 0).map((row) => row.split("&").map(cleanCell)).filter((cells) => cells.some((cell) => cell.length > 0));
|
|
688
|
+
if (rows.length === 0) return null;
|
|
689
|
+
const columns = Math.max(...rows.map((row) => row.length));
|
|
690
|
+
if (columns < 2) return rows.map((row) => row[0]).join("\n\n");
|
|
691
|
+
const pad = (cells) => `| ${[...cells, ...Array(columns - cells.length).fill("")].join(" | ")} |`;
|
|
692
|
+
const [header, ...body_] = rows;
|
|
693
|
+
return [pad(header), `| ${Array(columns).fill("---").join(" | ")} |`, ...body_.map(pad)].join(
|
|
694
|
+
"\n"
|
|
695
|
+
);
|
|
696
|
+
}
|
|
697
|
+
function convertLatexTables(markdown) {
|
|
698
|
+
if (!markdown.includes("\\begin{tabular}")) return markdown;
|
|
699
|
+
return markdown.replace(TABULAR_RE, (whole, body) => {
|
|
700
|
+
const converted = tabularToMarkdown(body);
|
|
701
|
+
return converted ?? whole;
|
|
702
|
+
});
|
|
703
|
+
}
|
|
704
|
+
|
|
671
705
|
// ../core/src/ocr/nvidia-provider.ts
|
|
672
706
|
var NVIDIA_OCR_MODEL = OCR_MODELS.nvidia;
|
|
673
707
|
var ENDPOINT4 = "https://integrate.api.nvidia.com/v1/chat/completions";
|
|
@@ -812,6 +846,28 @@ function collapseRepeatedLines(markdown) {
|
|
|
812
846
|
}
|
|
813
847
|
return kept.join("\n");
|
|
814
848
|
}
|
|
849
|
+
var TRUNCATED_COMPLETION_TOKENS = 3;
|
|
850
|
+
function completionTokensOf(raw) {
|
|
851
|
+
if (typeof raw !== "object" || raw === null) return null;
|
|
852
|
+
const usage = raw.usage;
|
|
853
|
+
if (typeof usage !== "object" || usage === null) return null;
|
|
854
|
+
const value = usage.completion_tokens ?? usage.output_tokens;
|
|
855
|
+
return typeof value === "number" ? value : null;
|
|
856
|
+
}
|
|
857
|
+
function looksTruncated(result) {
|
|
858
|
+
const tokens = completionTokensOf(result.raw);
|
|
859
|
+
return tokens !== null && tokens <= TRUNCATED_COMPLETION_TOKENS;
|
|
860
|
+
}
|
|
861
|
+
function stripEnclosingCodeFence(markdown) {
|
|
862
|
+
const trimmed = markdown.trim();
|
|
863
|
+
const opening = /^```([a-zA-Z]*)\r?\n?/.exec(trimmed);
|
|
864
|
+
if (!opening || !trimmed.endsWith("```") || trimmed.length < 6) return markdown;
|
|
865
|
+
const language = opening[1].toLowerCase();
|
|
866
|
+
if (language && language !== "markdown" && language !== "md") return markdown;
|
|
867
|
+
const inner = trimmed.slice(opening[0].length, -3);
|
|
868
|
+
if (inner.includes("```")) return markdown;
|
|
869
|
+
return inner.trim();
|
|
870
|
+
}
|
|
815
871
|
var REFUSAL_RE = /^(i['’]?m sorry|i cannot|i can['’]?t|i am (unable|not able)|sorry,|je suis désolé|désolé,|je ne peux pas|lo siento|mi dispiace|es tut mir leid|ich kann)/i;
|
|
816
872
|
function isProviderRefusal(markdown) {
|
|
817
873
|
return REFUSAL_RE.test(markdown.trim().slice(0, 80));
|
|
@@ -885,13 +941,23 @@ var ApiOcrRunner = class {
|
|
|
885
941
|
if (!apiKey2) {
|
|
886
942
|
throw new ConversionError("API_KEY_MISSING", `Aucune cl\xE9 API pour ${custom.name}`);
|
|
887
943
|
}
|
|
888
|
-
|
|
944
|
+
let result2 = await this.callCustomWithRetry(custom, img, sendMime, apiKey2, prompt);
|
|
945
|
+
if (looksTruncated(result2) && !isProviderRefusal(result2.markdown)) {
|
|
946
|
+
const second = await this.callCustomWithRetry(custom, img, sendMime, apiKey2, prompt);
|
|
947
|
+
if (second.markdown.trim().length > result2.markdown.trim().length) result2 = second;
|
|
948
|
+
}
|
|
889
949
|
if (isProviderRefusal(result2.markdown)) {
|
|
890
950
|
throw new ConversionError("OCR_REFUSED", `${custom.name} : refus du mod\xE8le`);
|
|
891
951
|
}
|
|
892
952
|
return {
|
|
893
|
-
markdown:
|
|
894
|
-
|
|
953
|
+
markdown: convertLatexTables(
|
|
954
|
+
stripEnclosingCodeFence(collapseRepeatedLines(result2.markdown))
|
|
955
|
+
),
|
|
956
|
+
meta: {
|
|
957
|
+
provider: custom.name,
|
|
958
|
+
model: custom.model,
|
|
959
|
+
...looksTruncated(result2) ? { truncated: true } : {}
|
|
960
|
+
}
|
|
895
961
|
};
|
|
896
962
|
}
|
|
897
963
|
const providerId = settings.ocrProvider;
|
|
@@ -903,13 +969,24 @@ var ApiOcrRunner = class {
|
|
|
903
969
|
`Aucune cl\xE9 API enregistr\xE9e pour le provider ${providerId}`
|
|
904
970
|
);
|
|
905
971
|
}
|
|
906
|
-
|
|
972
|
+
let result = await this.callWithRetry(provider, img, sendMime, apiKey, prompt);
|
|
973
|
+
if (looksTruncated(result) && !isProviderRefusal(result.markdown)) {
|
|
974
|
+
const second = await this.callWithRetry(provider, img, sendMime, apiKey, prompt);
|
|
975
|
+
if (second.markdown.trim().length > result.markdown.trim().length) result = second;
|
|
976
|
+
}
|
|
907
977
|
if (isProviderRefusal(result.markdown)) {
|
|
908
978
|
throw new ConversionError("OCR_REFUSED", `${provider.id} : refus du mod\xE8le`);
|
|
909
979
|
}
|
|
910
980
|
return {
|
|
911
|
-
markdown: collapseRepeatedLines(result.markdown),
|
|
912
|
-
meta: {
|
|
981
|
+
markdown: convertLatexTables(stripEnclosingCodeFence(collapseRepeatedLines(result.markdown))),
|
|
982
|
+
meta: {
|
|
983
|
+
provider: provider.id,
|
|
984
|
+
model: provider.model,
|
|
985
|
+
// Rejeu compris, le modèle s'est arrêté au premier libellé : le
|
|
986
|
+
// ConversionService en fait un avertissement dans le journal plutôt
|
|
987
|
+
// que d'enregistrer silencieusement une transcription amputée.
|
|
988
|
+
...looksTruncated(result) ? { truncated: true } : {}
|
|
989
|
+
}
|
|
913
990
|
};
|
|
914
991
|
}
|
|
915
992
|
/** Tentative initiale + retries backoff sur erreurs retryable uniquement */
|
|
@@ -1212,7 +1289,7 @@ var TranslationRunner = class {
|
|
|
1212
1289
|
const timer = setTimeout(() => controller.abort(), this.timeoutMs);
|
|
1213
1290
|
try {
|
|
1214
1291
|
const result = await attempt(controller.signal);
|
|
1215
|
-
return collapseRepeatedLines(result.markdown);
|
|
1292
|
+
return convertLatexTables(stripEnclosingCodeFence(collapseRepeatedLines(result.markdown)));
|
|
1216
1293
|
} catch (err) {
|
|
1217
1294
|
if (controller.signal.aborted) {
|
|
1218
1295
|
throw new ConversionError("TIMEOUT", `${providerLabel} : d\xE9lai de traduction d\xE9pass\xE9`);
|
|
@@ -1667,7 +1744,7 @@ async function expandInputs(inputs) {
|
|
|
1667
1744
|
});
|
|
1668
1745
|
continue;
|
|
1669
1746
|
}
|
|
1670
|
-
if (stat.isDirectory()) {
|
|
1747
|
+
if (stat.isDirectory() && !isPackageDirExtension((0, import_node_path5.extname)(path).slice(1))) {
|
|
1671
1748
|
await collectFromDir(path, jobs);
|
|
1672
1749
|
} else {
|
|
1673
1750
|
jobs.push({ input: path, kind: "file" });
|
|
@@ -1687,7 +1764,11 @@ async function collectFromDir(dir, jobs) {
|
|
|
1687
1764
|
if (entry.name.startsWith(".")) continue;
|
|
1688
1765
|
const full = (0, import_node_path5.join)(dir, entry.name);
|
|
1689
1766
|
if (entry.isDirectory()) {
|
|
1690
|
-
|
|
1767
|
+
if (isPackageDirExtension((0, import_node_path5.extname)(entry.name).slice(1))) {
|
|
1768
|
+
jobs.push({ input: full, kind: "file" });
|
|
1769
|
+
} else {
|
|
1770
|
+
await collectFromDir(full, jobs);
|
|
1771
|
+
}
|
|
1691
1772
|
} else if (entry.isFile() && mimeForExtension((0, import_node_path5.extname)(entry.name).slice(1))) {
|
|
1692
1773
|
jobs.push({ input: full, kind: "file" });
|
|
1693
1774
|
}
|
|
@@ -2700,7 +2781,7 @@ Exemples :
|
|
|
2700
2781
|
let singleFile = false;
|
|
2701
2782
|
if (paths.length === 1 && !/^https?:\/\//i.test(paths[0])) {
|
|
2702
2783
|
try {
|
|
2703
|
-
singleFile = (0, import_node_fs8.statSync)(paths[0]).isFile();
|
|
2784
|
+
singleFile = (0, import_node_fs8.statSync)(paths[0]).isFile() || isPackageDirExtension((0, import_node_path10.extname)(paths[0]).slice(1));
|
|
2704
2785
|
} catch {
|
|
2705
2786
|
singleFile = true;
|
|
2706
2787
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sygal-cli",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.5",
|
|
4
4
|
"description": "Sygal CLI - convert files and web pages to Markdown/JSON (local MarkItDown pipeline, OCR for images)",
|
|
5
5
|
"author": "Cyrill Semah",
|
|
6
6
|
"license": "SEE LICENSE IN LICENSE",
|
|
@@ -37,9 +37,9 @@
|
|
|
37
37
|
"@sygal/core": "0.1.0"
|
|
38
38
|
},
|
|
39
39
|
"optionalDependencies": {
|
|
40
|
-
"@sygal/sidecar-
|
|
41
|
-
"@sygal/sidecar-
|
|
42
|
-
"@sygal/sidecar-linux-x64": "0.3.
|
|
40
|
+
"@sygal/sidecar-win32-x64": "0.3.5",
|
|
41
|
+
"@sygal/sidecar-darwin-arm64": "0.3.5",
|
|
42
|
+
"@sygal/sidecar-linux-x64": "0.3.5"
|
|
43
43
|
},
|
|
44
44
|
"scripts": {
|
|
45
45
|
"build": "esbuild src/index.ts --bundle --platform=node --target=node20 --format=cjs --outfile=dist/index.js --external:sharp --external:gpt-tokenizer --external:commander --external:picocolors",
|