sygal-cli 0.5.3 → 0.5.6
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 +13 -2
- package/dist/index.js +141 -69
- package/package.json +4 -4
package/CHANGELOG.md
CHANGED
|
@@ -9,10 +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.5.
|
|
12
|
+
## 0.5.5
|
|
13
|
+
|
|
14
|
+
- Fix : exporter un document dont le nom contient des caractères illégaux sur Windows/macOS
|
|
15
|
+
(`/`, `:`, etc.) — fréquent dans les titres de pages web importées — ne plante plus en
|
|
16
|
+
`ENOENT`. Les noms trop longs (> 255 octets UTF-8, ex. titres avec emojis) sont désormais
|
|
17
|
+
tronqués sur une frontière de code point plutôt que de planter en `ENAMETOOLONG`.
|
|
18
|
+
- Fix : convertir une URL dont le fetch réussit (200) mais dont le contenu n'est pas
|
|
19
|
+
exploitable (site protégé par authwall/anti-bot JS-only, ex. LinkedIn) remonte désormais
|
|
20
|
+
le code d'erreur `url_empty_content` en mode `--json` au lieu de produire un fichier vide
|
|
21
|
+
avec un statut de succès.
|
|
22
|
+
|
|
23
|
+
## 0.5.3
|
|
13
24
|
|
|
14
25
|
- Aucun changement fonctionnel côté CLI/sidecars : bump lockstep pour rester
|
|
15
|
-
aligné avec la version de l'app desktop (1.5.
|
|
26
|
+
aligné avec la version de l'app desktop (1.5.3).
|
|
16
27
|
|
|
17
28
|
## 0.5.1
|
|
18
29
|
|
package/dist/index.js
CHANGED
|
@@ -102,42 +102,25 @@ function needsPngNormalization(ext) {
|
|
|
102
102
|
async function toPngBuffer(input) {
|
|
103
103
|
return (0, import_sharp.default)(input).png().toBuffer();
|
|
104
104
|
}
|
|
105
|
-
async function shrinkToMaxBytes(input, maxBytes) {
|
|
106
|
-
const QUALITY = 80;
|
|
107
|
-
let output = await (0, import_sharp.default)(input).jpeg({ quality: QUALITY }).toBuffer();
|
|
108
|
-
if (output.length <= maxBytes) return output;
|
|
109
|
-
const meta = await (0, import_sharp.default)(input).metadata();
|
|
110
|
-
let width = meta.width ?? 1600;
|
|
111
|
-
while (output.length > maxBytes && width > 200) {
|
|
112
|
-
width = Math.round(width * 0.75);
|
|
113
|
-
output = await (0, import_sharp.default)(input).resize({ width }).jpeg({ quality: QUALITY }).toBuffer();
|
|
114
|
-
}
|
|
115
|
-
let quality = QUALITY;
|
|
116
|
-
while (output.length > maxBytes && quality > 20) {
|
|
117
|
-
quality -= 15;
|
|
118
|
-
output = await (0, import_sharp.default)(input).resize({ width }).jpeg({ quality }).toBuffer();
|
|
119
|
-
}
|
|
120
|
-
return output;
|
|
121
|
-
}
|
|
122
105
|
|
|
123
106
|
// ../core/src/ocr-models.ts
|
|
124
107
|
var OCR_MODELS = {
|
|
125
108
|
openai: "gpt-4o",
|
|
126
109
|
mistral: "mistral-ocr-latest",
|
|
127
110
|
claude: "claude-sonnet-4-6",
|
|
128
|
-
// Bascule
|
|
129
|
-
//
|
|
130
|
-
//
|
|
131
|
-
//
|
|
132
|
-
//
|
|
133
|
-
// (
|
|
134
|
-
//
|
|
135
|
-
//
|
|
136
|
-
|
|
137
|
-
nvidia: "nvidia/nemotron-nano-12b-v2-vl",
|
|
111
|
+
// Bascule 02/09/2026 : nemotron-nano-12b-v2-vl (et toute la lignée nemotron
|
|
112
|
+
// vision-chat, nano-vl-8b-v1 y compris) retirée par NVIDIA — HTTP 410 "end
|
|
113
|
+
// of life" confirmé en direct (26/08/2026) sur les deux modèles. Remplacé
|
|
114
|
+
// par nemotron-ocr-v2 : modèle OCR dédié (pas un chat vision-langage), API
|
|
115
|
+
// /v1/cv séparée — voir nvidia-provider.ts. Détection de texte fiable
|
|
116
|
+
// (pas d'hallucination possible, contrairement aux chat vision), mais pas
|
|
117
|
+
// de description d'image ni de traduction : NVIDIA_FALLBACK_MODEL
|
|
118
|
+
// (llama-3.2-90b-vision-instruct) prend le relais pour ces deux cas.
|
|
119
|
+
nvidia: "nvidia/nemotron-ocr-v2",
|
|
138
120
|
gemini: "gemini-2.0-flash"
|
|
139
121
|
};
|
|
140
122
|
var MISTRAL_TRANSLATE_MODEL = "mistral-large-latest";
|
|
123
|
+
var NVIDIA_FALLBACK_MODEL = "meta/llama-3.2-90b-vision-instruct";
|
|
141
124
|
|
|
142
125
|
// ../core/src/sidecar/manager.ts
|
|
143
126
|
var import_node_child_process = require("node:child_process");
|
|
@@ -724,63 +707,126 @@ function convertLatexTables(markdown) {
|
|
|
724
707
|
}
|
|
725
708
|
|
|
726
709
|
// ../core/src/ocr/nvidia-provider.ts
|
|
710
|
+
var import_sharp2 = __toESM(require("sharp"));
|
|
727
711
|
var NVIDIA_OCR_MODEL = OCR_MODELS.nvidia;
|
|
728
|
-
var
|
|
729
|
-
var
|
|
712
|
+
var OCR_ENDPOINT = "https://ai.api.nvidia.com/v1/cv/nvidia/nemotron-ocr-v2";
|
|
713
|
+
var CHAT_ENDPOINT2 = "https://integrate.api.nvidia.com/v1/chat/completions";
|
|
714
|
+
function median(nums) {
|
|
715
|
+
if (nums.length === 0) return 0;
|
|
716
|
+
const sorted = [...nums].sort((a, b) => a - b);
|
|
717
|
+
const mid = Math.floor(sorted.length / 2);
|
|
718
|
+
return sorted.length % 2 === 1 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
|
|
719
|
+
}
|
|
720
|
+
function groupLines(blocks) {
|
|
721
|
+
const lines = [];
|
|
722
|
+
for (const block of [...blocks].sort((a, b) => a.top - b.top)) {
|
|
723
|
+
const current = lines[lines.length - 1];
|
|
724
|
+
if (current) {
|
|
725
|
+
const envTop = Math.min(...current.map((b) => b.top));
|
|
726
|
+
const envBottom = Math.max(...current.map((b) => b.bottom));
|
|
727
|
+
const maxHeight = Math.max(...current.map((b) => b.height), block.height);
|
|
728
|
+
const overlaps = block.top < envBottom && block.bottom > envTop;
|
|
729
|
+
const newHeight = Math.max(envBottom, block.bottom) - Math.min(envTop, block.top);
|
|
730
|
+
if (overlaps && newHeight <= maxHeight * 1.6) {
|
|
731
|
+
current.push(block);
|
|
732
|
+
continue;
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
lines.push([block]);
|
|
736
|
+
}
|
|
737
|
+
return lines;
|
|
738
|
+
}
|
|
739
|
+
var BULLET_RE = /^[•▪◦○*·-]\s+/;
|
|
740
|
+
function reconstructReadingOrder(detections) {
|
|
741
|
+
const blocks = detections.map((d) => {
|
|
742
|
+
const text = d.text_prediction?.text?.trim();
|
|
743
|
+
const points = d.bounding_box?.points;
|
|
744
|
+
if (!text || !points || points.length === 0) return null;
|
|
745
|
+
const ys = points.map((p) => p.y);
|
|
746
|
+
const xs = points.map((p) => p.x);
|
|
747
|
+
const top = Math.min(...ys);
|
|
748
|
+
const bottom = Math.max(...ys);
|
|
749
|
+
return { text, top, bottom, left: Math.min(...xs), height: bottom - top };
|
|
750
|
+
}).filter((b) => b !== null);
|
|
751
|
+
const lines = groupLines(blocks);
|
|
752
|
+
const typicalHeight = median(lines.map((line) => median(line.map((b) => b.height)))) || 0.02;
|
|
753
|
+
const out = [];
|
|
754
|
+
let previousBottom = null;
|
|
755
|
+
for (const line of lines) {
|
|
756
|
+
const top = Math.min(...line.map((b) => b.top));
|
|
757
|
+
const bottom = Math.max(...line.map((b) => b.bottom));
|
|
758
|
+
const height = median(line.map((b) => b.height));
|
|
759
|
+
if (previousBottom !== null && top - previousBottom > typicalHeight * 0.9) out.push("");
|
|
760
|
+
previousBottom = bottom;
|
|
761
|
+
let text = [...line].sort((a, b) => a.left - b.left).map((b) => b.text).join(" ");
|
|
762
|
+
const bullet = text.match(BULLET_RE);
|
|
763
|
+
if (bullet) text = `- ${text.slice(bullet[0].length)}`;
|
|
764
|
+
else if (line.length === 1 && height > typicalHeight * 1.25 && text.split(" ").length <= 8) {
|
|
765
|
+
text = `## ${text}`;
|
|
766
|
+
}
|
|
767
|
+
out.push(text);
|
|
768
|
+
}
|
|
769
|
+
return out.join("\n");
|
|
770
|
+
}
|
|
771
|
+
async function describeOrTranslateFallback(img, mime, apiKey, signal, prompt) {
|
|
772
|
+
const content = [{ type: "text", text: prompt }];
|
|
773
|
+
if (img && mime) {
|
|
774
|
+
content.push({ type: "image_url", image_url: { url: toDataUrl(img, mime) } });
|
|
775
|
+
}
|
|
776
|
+
const response = await fetch(CHAT_ENDPOINT2, {
|
|
777
|
+
method: "POST",
|
|
778
|
+
signal,
|
|
779
|
+
headers: { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}` },
|
|
780
|
+
body: JSON.stringify({
|
|
781
|
+
model: NVIDIA_FALLBACK_MODEL,
|
|
782
|
+
max_tokens: 4096,
|
|
783
|
+
frequency_penalty: 0.4,
|
|
784
|
+
temperature: 0,
|
|
785
|
+
messages: [{ role: "user", content }]
|
|
786
|
+
})
|
|
787
|
+
});
|
|
788
|
+
if (!response.ok) throw httpStatusToOcrError(response.status, "nvidia");
|
|
789
|
+
const payload = await response.json();
|
|
790
|
+
const markdown = payload.choices?.[0]?.message?.content;
|
|
791
|
+
if (typeof markdown !== "string" || markdown.length === 0) {
|
|
792
|
+
throw new OcrError("UNKNOWN", "nvidia : r\xE9ponse sans contenu");
|
|
793
|
+
}
|
|
794
|
+
return { markdown, raw: payload };
|
|
795
|
+
}
|
|
730
796
|
var nvidiaProvider = {
|
|
731
797
|
id: "nvidia",
|
|
732
798
|
model: NVIDIA_OCR_MODEL,
|
|
733
799
|
async convertImage(img, mime, apiKey, signal, prompt) {
|
|
800
|
+
if (!prompt.includes(NO_TEXT_SENTINEL)) {
|
|
801
|
+
return describeOrTranslateFallback(img, mime, apiKey, signal, prompt);
|
|
802
|
+
}
|
|
734
803
|
let payloadImg = img;
|
|
735
804
|
let payloadMime = mime;
|
|
736
|
-
if (payloadMime
|
|
737
|
-
payloadImg = await
|
|
805
|
+
if (payloadMime !== "image/png" && payloadMime !== "image/jpeg") {
|
|
806
|
+
payloadImg = await (0, import_sharp2.default)(img).jpeg({ quality: 90 }).toBuffer();
|
|
738
807
|
payloadMime = "image/jpeg";
|
|
739
808
|
}
|
|
740
|
-
const response = await fetch(
|
|
809
|
+
const response = await fetch(OCR_ENDPOINT, {
|
|
741
810
|
method: "POST",
|
|
742
811
|
signal,
|
|
743
|
-
headers: {
|
|
744
|
-
"Content-Type": "application/json",
|
|
745
|
-
Authorization: `Bearer ${apiKey}`
|
|
746
|
-
},
|
|
812
|
+
headers: { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}` },
|
|
747
813
|
body: JSON.stringify({
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
// Llama vision peut dégénérer en boucle de répétition sans pénalité
|
|
751
|
-
// (observé en réel : même phrase répétée ~200 fois jusqu'à max_tokens).
|
|
752
|
-
frequency_penalty: 0.4,
|
|
753
|
-
// Petit modèle (11B) : sans température 0 ni cadrage renforcé, il "interprète"
|
|
754
|
-
// (résumé en anglais observé en réel 12/07/2026 au lieu d'une transcription).
|
|
755
|
-
// ATTENTION : pas de message system — le endpoint NIM llama vision rejette
|
|
756
|
-
// les requêtes system + image (constaté en réel 12/07/2026, appel en échec).
|
|
757
|
-
temperature: 0,
|
|
758
|
-
messages: [
|
|
759
|
-
{
|
|
760
|
-
role: "user",
|
|
761
|
-
content: [
|
|
762
|
-
{
|
|
763
|
-
type: "text",
|
|
764
|
-
text: "You are an OCR engine. You copy text from images character by character. You never summarize, never translate, never comment, never add steps or headings of your own. You only describe the image in the single case where it contains no readable text at all, exactly as instructed below.\n\n" + prompt
|
|
765
|
-
},
|
|
766
|
-
{ type: "image_url", image_url: { url: toDataUrl(payloadImg, payloadMime) } }
|
|
767
|
-
]
|
|
768
|
-
}
|
|
769
|
-
]
|
|
814
|
+
input: [{ type: "image_url", url: toDataUrl(payloadImg, payloadMime) }],
|
|
815
|
+
merge_levels: ["paragraph"]
|
|
770
816
|
})
|
|
771
817
|
});
|
|
772
818
|
if (!response.ok) throw httpStatusToOcrError(response.status, "nvidia");
|
|
773
819
|
const payload = await response.json();
|
|
774
|
-
const
|
|
775
|
-
if (
|
|
776
|
-
|
|
820
|
+
const detections = payload.data?.[0]?.text_detections ?? [];
|
|
821
|
+
if (detections.length === 0) {
|
|
822
|
+
return { markdown: NO_TEXT_SENTINEL, raw: payload };
|
|
777
823
|
}
|
|
778
|
-
return { markdown, raw: payload };
|
|
824
|
+
return { markdown: reconstructReadingOrder(detections), raw: payload };
|
|
779
825
|
},
|
|
780
826
|
translateText(text, apiKey, signal, prompt) {
|
|
781
827
|
return callOpenAiCompatibleChatText(
|
|
782
|
-
|
|
783
|
-
|
|
828
|
+
CHAT_ENDPOINT2,
|
|
829
|
+
NVIDIA_FALLBACK_MODEL,
|
|
784
830
|
apiKey,
|
|
785
831
|
signal,
|
|
786
832
|
prompt,
|
|
@@ -788,19 +834,19 @@ var nvidiaProvider = {
|
|
|
788
834
|
"nvidia",
|
|
789
835
|
8192,
|
|
790
836
|
0.4
|
|
791
|
-
// même garde-fou anti-répétition
|
|
837
|
+
// même garde-fou anti-répétition qu'en OCR (voir callOpenAiCompatibleChatText)
|
|
792
838
|
);
|
|
793
839
|
}
|
|
794
840
|
};
|
|
795
841
|
|
|
796
842
|
// ../core/src/ocr/openai-provider.ts
|
|
797
843
|
var OPENAI_OCR_MODEL = OCR_MODELS.openai;
|
|
798
|
-
var
|
|
844
|
+
var ENDPOINT4 = "https://api.openai.com/v1/chat/completions";
|
|
799
845
|
var openAiProvider = {
|
|
800
846
|
id: "openai",
|
|
801
847
|
model: OPENAI_OCR_MODEL,
|
|
802
848
|
async convertImage(img, mime, apiKey, signal, prompt) {
|
|
803
|
-
const response = await fetch(
|
|
849
|
+
const response = await fetch(ENDPOINT4, {
|
|
804
850
|
method: "POST",
|
|
805
851
|
signal,
|
|
806
852
|
headers: {
|
|
@@ -831,7 +877,7 @@ var openAiProvider = {
|
|
|
831
877
|
},
|
|
832
878
|
translateText(text, apiKey, signal, prompt) {
|
|
833
879
|
return callOpenAiCompatibleChatText(
|
|
834
|
-
|
|
880
|
+
ENDPOINT4,
|
|
835
881
|
OPENAI_OCR_MODEL,
|
|
836
882
|
apiKey,
|
|
837
883
|
signal,
|
|
@@ -1522,8 +1568,31 @@ function buildJsonEnvelope(doc) {
|
|
|
1522
1568
|
function serializeJsonExport(doc) {
|
|
1523
1569
|
return JSON.stringify(buildJsonEnvelope(doc), null, 2);
|
|
1524
1570
|
}
|
|
1571
|
+
var ILLEGAL_FILENAME_CHARS = /[\\/:*?"<>|\x00-\x1f]/g;
|
|
1572
|
+
function sanitizeFilename(name) {
|
|
1573
|
+
return name.replace(ILLEGAL_FILENAME_CHARS, " ").replace(/\s+/g, " ").trim();
|
|
1574
|
+
}
|
|
1575
|
+
var MAX_FILENAME_BYTES = 255;
|
|
1576
|
+
var RESERVED_SUFFIX_BYTES = 11;
|
|
1577
|
+
var encoder = new TextEncoder();
|
|
1578
|
+
function truncateToByteLength(name, maxBytes) {
|
|
1579
|
+
if (encoder.encode(name).length <= maxBytes) return name;
|
|
1580
|
+
let result = "";
|
|
1581
|
+
let bytes = 0;
|
|
1582
|
+
for (const char of name) {
|
|
1583
|
+
const charBytes = encoder.encode(char).length;
|
|
1584
|
+
if (bytes + charBytes > maxBytes) break;
|
|
1585
|
+
result += char;
|
|
1586
|
+
bytes += charBytes;
|
|
1587
|
+
}
|
|
1588
|
+
return result.trim();
|
|
1589
|
+
}
|
|
1525
1590
|
async function resolveCollisionPath(dir, stem, ext) {
|
|
1526
|
-
const
|
|
1591
|
+
const safeStem = truncateToByteLength(
|
|
1592
|
+
sanitizeFilename(stem),
|
|
1593
|
+
MAX_FILENAME_BYTES - RESERVED_SUFFIX_BYTES
|
|
1594
|
+
);
|
|
1595
|
+
const candidate = (n2) => (0, import_node_path3.join)(dir, n2 === 0 ? `${safeStem}${ext}` : `${safeStem} (${n2})${ext}`);
|
|
1527
1596
|
let n = 0;
|
|
1528
1597
|
while (n < 999) {
|
|
1529
1598
|
try {
|
|
@@ -1709,6 +1778,7 @@ var CONVERSION_CODE_MAP = {
|
|
|
1709
1778
|
TRANSLATION_INCOHERENT: "translation_incoherent",
|
|
1710
1779
|
URL_FORBIDDEN: "url_forbidden",
|
|
1711
1780
|
URL_UNREACHABLE: "url_unreachable",
|
|
1781
|
+
URL_EMPTY_CONTENT: "url_empty_content",
|
|
1712
1782
|
HEIC_HEVC_UNSUPPORTED: "heic_hevc_unsupported",
|
|
1713
1783
|
IMAGE_DECODE_UNSUPPORTED: "image_decode_unsupported",
|
|
1714
1784
|
UNKNOWN: "unknown"
|
|
@@ -1741,6 +1811,8 @@ function toCliError(err) {
|
|
|
1741
1811
|
return { code: "url_forbidden", message: err.message };
|
|
1742
1812
|
case "URL_UNREACHABLE":
|
|
1743
1813
|
return { code: "url_unreachable", message: err.message };
|
|
1814
|
+
case "URL_EMPTY_CONTENT":
|
|
1815
|
+
return { code: "url_empty_content", message: err.message };
|
|
1744
1816
|
default:
|
|
1745
1817
|
return { code: "conversion_failed", message: err.message };
|
|
1746
1818
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sygal-cli",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.6",
|
|
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-darwin-arm64": "0.5.
|
|
41
|
-
"@sygal/sidecar-win32-x64": "0.5.
|
|
42
|
-
"@sygal/sidecar-linux-x64": "0.5.
|
|
40
|
+
"@sygal/sidecar-darwin-arm64": "0.5.6",
|
|
41
|
+
"@sygal/sidecar-win32-x64": "0.5.6",
|
|
42
|
+
"@sygal/sidecar-linux-x64": "0.5.6"
|
|
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",
|