formatferry 1.0.29 → 1.0.31

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.
Files changed (3) hide show
  1. package/README.md +39 -5
  2. package/dist/index.js +97 -26
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -44,11 +44,44 @@ already have real text. It is deliberately absent here — the CLI ships no WASM
44
44
  OCR engine, which keeps the install small and the conversion fully offline.
45
45
 
46
46
  For a PDF that mixes real text pages with scanned inserts, the conversion
47
- **succeeds** and the scanned pages come through empty. The document has enough
48
- text elsewhere to avoid the `IMAGE_ONLY` error above, so the exit code is 0 and
49
- the only sign is a page heading with nothing under it. Check the output when a
50
- PDF may contain scans — for this case the CLI cannot tell you which pages it
51
- could not read, because no OCR ran and so nothing reported a per-page failure.
47
+ still **succeeds** with exit code 0 — the document has enough text elsewhere
48
+ to avoid the `IMAGE_ONLY` error above — and the scanned pages come through
49
+ empty. That loss is no longer silent: stderr carries one warning naming the
50
+ pages that could not be fully read:
51
+
52
+ ```
53
+ Warning: Page(s) 3 could not be fully read: they have little or no extractable text and OCR is not available in this environment, so their content may be missing or incomplete.
54
+ ```
55
+
56
+ Pipelines that would rather fail than accept a warned conversion can pass
57
+ `--strict`. The complete output is still written (to stdout or the `-o`
58
+ file), and the process then exits 1 after a summary line:
59
+
60
+ ```
61
+ $ formatferry -i mixed.pdf --strict; echo "exit=$?"
62
+ Warning: Page(s) 3 could not be fully read: they have little or no extractable text and OCR is not available in this environment, so their content may be missing or incomplete.
63
+ Error: --strict: conversion produced 1 warning(s) [pdf.warnings.ocrUnavailablePages].
64
+ exit=1
65
+ ```
66
+
67
+ The summary names the machine-readable warning code(s) in brackets, so
68
+ pipelines can triage without parsing prose.
69
+
70
+ Be aware that **any** warning trips `--strict`, not only page-loss warnings:
71
+ advisory notices count too. For example, the xlsx warning "This spreadsheet
72
+ has many columns" fires on wide-but-ordinary spreadsheets and would exit 1
73
+ under `--strict`. This any-warning meaning of bare `--strict` is the permanent
74
+ contract: if a curated data-loss-only mode is ever added, it will be a
75
+ separate additive spelling (for example `--strict=loss`) — bare `--strict`
76
+ will not change meaning underneath existing pipelines.
77
+
78
+ Under `--batch`, each file's warnings are printed to stderr as
79
+ `Warning (<file>): <message>`, and `--strict` exits 1 after the batch
80
+ completes (all outputs still written) if any file warned.
81
+
82
+ One caveat for MCP users: whether the external `formatferry-mcp` wrapper
83
+ forwards `--strict` to the CLI has not been verified; if it filters unknown
84
+ flags, the flag is inert there.
52
85
 
53
86
  Conversion warnings that *are* produced (for example unusual table structures)
54
87
  are written to stderr, never stdout, so `formatferry -i doc.pdf > out.md` keeps
@@ -72,6 +105,7 @@ formatferry [options]
72
105
  | `--url <url>` | Fetch and convert a web article (requires API key) |
73
106
  | `--batch <glob>` | Batch convert files matching a glob pattern (Premium) |
74
107
  | `--output-dir <dir>` | Output directory for batch mode |
108
+ | `--strict` | Exit with code 1 if the conversion produced any warnings (including advisory ones) |
75
109
 
76
110
  #### Input Modes
77
111
 
package/dist/index.js CHANGED
@@ -58500,6 +58500,25 @@ function normalizeAtlassianHTML(html, domParser) {
58500
58500
 
58501
58501
  // ../../src/shared/confluence-slack-services.ts
58502
58502
  var import_turndown = __toESM(require_turndown_cjs(), 1);
58503
+ function escapeSlackEntities(text) {
58504
+ return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
58505
+ }
58506
+ var ZERO_WIDTH_SPACE = "\u200B";
58507
+ function escapeSlackText(text) {
58508
+ return escapeSlackEntities(text).replace(
58509
+ /(^|[^\p{L}\p{N}])([*_~`])/gu,
58510
+ `$1${ZERO_WIDTH_SPACE}$2`
58511
+ );
58512
+ }
58513
+ function slackLabel(text) {
58514
+ return text.replace(/\|/g, "\uFF5C");
58515
+ }
58516
+ function escapeConfluenceText(text) {
58517
+ return text.replace(/([{}[\]\\])/g, "\\$1").replace(/(^|\n)([*#-])(?=\s)/g, "$1\\$2").replace(/(^|[^\p{L}\p{N}\\])([*_+\-^~])(?=\S)/gu, "$1\\$2").replace(
58518
+ /(?<=\S)([*_+\-^~])(?=$|[^\p{L}\p{N}])/gu,
58519
+ (m, c, offset, str) => str[offset - 1] === "\\" ? m : `\\${c}`
58520
+ );
58521
+ }
58503
58522
  function createSlackService(deps) {
58504
58523
  const service = new import_turndown.default({
58505
58524
  headingStyle: "atx",
@@ -58509,6 +58528,7 @@ function createSlackService(deps) {
58509
58528
  fence: "```",
58510
58529
  emDelimiter: "_"
58511
58530
  });
58531
+ service.escape = escapeSlackText;
58512
58532
  deps.addListRules(service);
58513
58533
  deps.addPdfPageMarkerRules(service);
58514
58534
  service.addRule("slackBold", {
@@ -58557,12 +58577,33 @@ function createSlackService(deps) {
58557
58577
  const fontFamily = style.toLowerCase();
58558
58578
  return fontFamily.includes("monospace") || fontFamily.includes("courier") || fontFamily.includes("consolas") || fontFamily.includes("monaco") || fontFamily.includes("menlo") || fontFamily.includes("source code") || fontFamily.includes("fira code") || fontFamily.includes("roboto mono") || fontFamily.includes("ubuntu mono");
58559
58579
  },
58560
- replacement: (content) => {
58561
- const trimmed = content.trim();
58580
+ replacement: (content, node) => {
58581
+ const trimmed = escapeSlackEntities((node.textContent || "").trim());
58562
58582
  if (!trimmed) return content;
58563
58583
  return trimmed.includes("`") ? `\`\` ${trimmed} \`\`` : `\`${trimmed}\``;
58564
58584
  }
58565
58585
  });
58586
+ service.addRule("slackCodeBlock", {
58587
+ filter: (node) => node.nodeName === "PRE",
58588
+ replacement: (_content, node) => {
58589
+ const code = (node.textContent || "").replace(/\n$/, "");
58590
+ return `
58591
+
58592
+ \`\`\`
58593
+ ${escapeSlackEntities(code)}
58594
+ \`\`\`
58595
+
58596
+ `;
58597
+ }
58598
+ });
58599
+ service.addRule("slackInlineCode", {
58600
+ filter: (node) => node.nodeName === "CODE" && node.parentNode?.nodeName !== "PRE",
58601
+ replacement: (content, node) => {
58602
+ const code = escapeSlackEntities(node.textContent || "");
58603
+ if (!code) return content;
58604
+ return code.includes("`") ? `\`\` ${code} \`\`` : `\`${code}\``;
58605
+ }
58606
+ });
58566
58607
  service.addRule("slackHeading", {
58567
58608
  filter: ["h1", "h2", "h3", "h4", "h5", "h6"],
58568
58609
  replacement: (content) => content.trim() ? `*${content.trim()}*
@@ -58599,8 +58640,8 @@ function createSlackService(deps) {
58599
58640
  service.addRule("slackLink", {
58600
58641
  filter: (node) => node.nodeName === "A" && !!node.getAttribute("href"),
58601
58642
  replacement: (content, node) => {
58602
- const href = node.getAttribute("href") || "";
58603
- const text = content.trim().replace(/\|/g, " ");
58643
+ const href = escapeSlackEntities(node.getAttribute("href") || "");
58644
+ const text = slackLabel(content.trim());
58604
58645
  if (!text || text === href) return `<${href}>`;
58605
58646
  return `<${href}|${text}>`;
58606
58647
  }
@@ -58608,15 +58649,15 @@ function createSlackService(deps) {
58608
58649
  service.addRule("slackImage", {
58609
58650
  filter: "img",
58610
58651
  replacement: (_content, node) => {
58611
- const src = node.getAttribute("src") || "";
58612
- const alt = (node.getAttribute("alt") || "image").replace(/\|/g, " ");
58652
+ const src = escapeSlackEntities(node.getAttribute("src") || "");
58653
+ const alt = slackLabel(escapeSlackEntities(node.getAttribute("alt") || "image"));
58613
58654
  return `<${src}|${alt}>`;
58614
58655
  }
58615
58656
  });
58616
58657
  service.addRule("slackTable", {
58617
58658
  filter: "table",
58618
58659
  replacement: (_content, node) => {
58619
- const plainText = deps.tableToPlainText(node);
58660
+ const plainText = escapeSlackEntities(deps.tableToPlainText(node));
58620
58661
  return `\`\`\`
58621
58662
  ${plainText}
58622
58663
  \`\`\`
@@ -58664,6 +58705,7 @@ function createConfluenceService(deps) {
58664
58705
  codeBlockStyle: "fenced",
58665
58706
  emDelimiter: "_"
58666
58707
  });
58708
+ service.escape = escapeConfluenceText;
58667
58709
  deps.addPdfPageMarkerRules(service);
58668
58710
  service.addRule("confluenceHeading", {
58669
58711
  filter: ["h1", "h2", "h3", "h4", "h5", "h6"],
@@ -58754,7 +58796,10 @@ ${codeContent}
58754
58796
  const fontFamily = style.toLowerCase();
58755
58797
  return fontFamily.includes("monospace") || fontFamily.includes("courier") || fontFamily.includes("consolas") || fontFamily.includes("monaco") || fontFamily.includes("menlo") || fontFamily.includes("source code") || fontFamily.includes("fira code") || fontFamily.includes("roboto mono") || fontFamily.includes("ubuntu mono");
58756
58798
  },
58757
- replacement: (content) => content.trim() ? `{{${content.trim().replace(/}+/g, (m) => m.split("").join(" "))}}}` : content
58799
+ replacement: (content, node) => {
58800
+ const code = (node.textContent || "").trim();
58801
+ return code ? `{{${code.replace(/}+/g, (m) => m.split("").join(" "))}}}` : content;
58802
+ }
58758
58803
  });
58759
58804
  service.addRule("confluenceLink", {
58760
58805
  filter: (node) => node.nodeName === "A" && !!node.getAttribute("href"),
@@ -61636,6 +61681,9 @@ function localize2(translate, key, vars) {
61636
61681
  if (key === "pdf.warnings.ocrPageSkippedTooLarge") {
61637
61682
  return `Page(s) ${vars.pages} are too large to OCR and were skipped.`;
61638
61683
  }
61684
+ if (key === "pdf.warnings.ocrUnavailablePages") {
61685
+ return `Page(s) ${vars.pages} could not be fully read: they have little or no extractable text and OCR is not available in this environment, so their content may be missing or incomplete.`;
61686
+ }
61639
61687
  return key;
61640
61688
  }
61641
61689
  var PdfAdapter = class {
@@ -62039,6 +62087,9 @@ var PdfAdapter = class {
62039
62087
  }
62040
62088
  }
62041
62089
  }
62090
+ if (routedPages.length > 0 && !fullyScanned && !ocrRan) {
62091
+ pushCodedWarning("pdf.warnings.ocrUnavailablePages", routedPages);
62092
+ }
62042
62093
  let documentHasUsableText = false;
62043
62094
  if (ocrRan) {
62044
62095
  const { isUsableText: isUsableText2 } = await Promise.resolve().then(() => (init_pdf_ocr_adapter(), pdf_ocr_adapter_exports));
@@ -63941,7 +63992,7 @@ async function binaryToMarkdown(buffer, type, format) {
63941
63992
  preserveLists: true,
63942
63993
  customRules: []
63943
63994
  });
63944
- return { markdown: converted.markdown, warnings: result.warnings };
63995
+ return { markdown: converted.markdown, warnings: result.warnings, warningCodes: result.warningCodes };
63945
63996
  }
63946
63997
  case "pdf": {
63947
63998
  const result = await pdfAdapter.parseToText(toArrayBuffer());
@@ -63952,7 +64003,7 @@ async function binaryToMarkdown(buffer, type, format) {
63952
64003
  preserveLists: true,
63953
64004
  customRules: []
63954
64005
  });
63955
- return { markdown: converted.markdown, warnings: result.warnings };
64006
+ return { markdown: converted.markdown, warnings: result.warnings, warningCodes: result.warningCodes };
63956
64007
  }
63957
64008
  case "xlsx":
63958
64009
  case "csv": {
@@ -63964,7 +64015,7 @@ async function binaryToMarkdown(buffer, type, format) {
63964
64015
  preserveLists: true,
63965
64016
  customRules: []
63966
64017
  });
63967
- return { markdown: converted.markdown, warnings: result.warnings };
64018
+ return { markdown: converted.markdown, warnings: result.warnings, warningCodes: result.warningCodes };
63968
64019
  }
63969
64020
  case "json": {
63970
64021
  const result = await dataAdapter.parseJsonToHtml(toArrayBuffer());
@@ -63975,7 +64026,7 @@ async function binaryToMarkdown(buffer, type, format) {
63975
64026
  preserveLists: true,
63976
64027
  customRules: []
63977
64028
  });
63978
- return { markdown: converted.markdown, warnings: result.warnings };
64029
+ return { markdown: converted.markdown, warnings: result.warnings, warningCodes: result.warningCodes };
63979
64030
  }
63980
64031
  case "xml": {
63981
64032
  const result = await dataAdapter.parseXmlToHtml(toArrayBuffer());
@@ -63986,7 +64037,7 @@ async function binaryToMarkdown(buffer, type, format) {
63986
64037
  preserveLists: true,
63987
64038
  customRules: []
63988
64039
  });
63989
- return { markdown: converted.markdown, warnings: result.warnings };
64040
+ return { markdown: converted.markdown, warnings: result.warnings, warningCodes: result.warningCodes };
63990
64041
  }
63991
64042
  case "pptx": {
63992
64043
  const result = await pptxAdapter.parseToHtml(toArrayBuffer());
@@ -63997,7 +64048,7 @@ async function binaryToMarkdown(buffer, type, format) {
63997
64048
  preserveLists: true,
63998
64049
  customRules: []
63999
64050
  });
64000
- return { markdown: converted.markdown, warnings: result.warnings };
64051
+ return { markdown: converted.markdown, warnings: result.warnings, warningCodes: result.warningCodes };
64001
64052
  }
64002
64053
  }
64003
64054
  }
@@ -64111,6 +64162,7 @@ async function convertCommand(options) {
64111
64162
  }
64112
64163
  let markdown;
64113
64164
  let conversionWarnings = [];
64165
+ let conversionWarningCodes = [];
64114
64166
  if (options.input) {
64115
64167
  let fileBuffer;
64116
64168
  try {
@@ -64139,6 +64191,7 @@ async function convertCommand(options) {
64139
64191
  const binary = await binaryToMarkdown(fileBuffer, inputType, format);
64140
64192
  markdown = binary.markdown;
64141
64193
  conversionWarnings = binary.warnings;
64194
+ conversionWarningCodes = binary.warningCodes ?? [];
64142
64195
  }
64143
64196
  } else {
64144
64197
  const html = await readStdin();
@@ -64170,6 +64223,13 @@ async function convertCommand(options) {
64170
64223
  } else {
64171
64224
  process.stdout.write(markdown);
64172
64225
  }
64226
+ if (options.strict && conversionWarnings.length > 0) {
64227
+ const codes = [...new Set(conversionWarningCodes.map((w) => w.code))];
64228
+ const codeList = codes.length > 0 ? ` [${codes.join(", ")}]` : "";
64229
+ console.error(`Error: --strict: conversion produced ${conversionWarnings.length} warning(s)${codeList}.`);
64230
+ process.exitCode = 1;
64231
+ return;
64232
+ }
64173
64233
  }
64174
64234
 
64175
64235
  // src/commands/batch.ts
@@ -64195,38 +64255,38 @@ async function convertFile(filePath, format) {
64195
64255
  preserveLists: true,
64196
64256
  customRules: []
64197
64257
  });
64198
- return result.markdown;
64258
+ return { markdown: result.markdown, warnings: [] };
64199
64259
  }
64200
64260
  const arrayBuffer = buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength);
64201
64261
  if (ext === ".docx") {
64202
64262
  const r = await docxAdapter.parseToHtml(arrayBuffer);
64203
64263
  const c = await engine.convert(sanitizeHTML(r.html), { format, preserveTables: true, preserveLists: true, customRules: [] });
64204
- return c.markdown;
64264
+ return { markdown: c.markdown, warnings: r.warnings ?? [] };
64205
64265
  }
64206
64266
  if (ext === ".pdf") {
64207
64267
  const r = await pdfAdapter.parseToText(arrayBuffer);
64208
64268
  const c = await engine.convert(sanitizeHTML(r.html), { format, preserveTables: true, preserveLists: true, customRules: [] });
64209
- return c.markdown;
64269
+ return { markdown: c.markdown, warnings: r.warnings ?? [] };
64210
64270
  }
64211
64271
  if (ext === ".xlsx" || ext === ".csv") {
64212
64272
  const r = await spreadsheetAdapter.parseToHtml(arrayBuffer, ext.slice(1), 0);
64213
64273
  const c = await engine.convert(sanitizeHTML(r.html), { format, preserveTables: true, preserveLists: true, customRules: [] });
64214
- return c.markdown;
64274
+ return { markdown: c.markdown, warnings: r.warnings ?? [] };
64215
64275
  }
64216
64276
  if (ext === ".json") {
64217
64277
  const r = await dataAdapter.parseJsonToHtml(arrayBuffer);
64218
64278
  const c = await engine.convert(sanitizeHTML(r.html), { format, preserveTables: true, preserveLists: true, customRules: [] });
64219
- return c.markdown;
64279
+ return { markdown: c.markdown, warnings: r.warnings ?? [] };
64220
64280
  }
64221
64281
  if (ext === ".xml") {
64222
64282
  const r = await dataAdapter.parseXmlToHtml(arrayBuffer);
64223
64283
  const c = await engine.convert(sanitizeHTML(r.html), { format, preserveTables: true, preserveLists: true, customRules: [] });
64224
- return c.markdown;
64284
+ return { markdown: c.markdown, warnings: r.warnings ?? [] };
64225
64285
  }
64226
64286
  if (ext === ".pptx") {
64227
64287
  const r = await pptxAdapter.parseToHtml(arrayBuffer);
64228
64288
  const c = await engine.convert(sanitizeHTML(r.html), { format, preserveTables: true, preserveLists: true, customRules: [] });
64229
- return c.markdown;
64289
+ return { markdown: c.markdown, warnings: r.warnings ?? [] };
64230
64290
  }
64231
64291
  throw new Error(`Unsupported file type: ${ext}`);
64232
64292
  }
@@ -64268,11 +64328,17 @@ async function batchCommand(options) {
64268
64328
  }
64269
64329
  const format = options.format ?? "github";
64270
64330
  if (options.outputDir) mkdirSync2(options.outputDir, { recursive: true });
64271
- let successful = 0, failed = 0;
64331
+ let successful = 0, failed = 0, batchWarnings = 0;
64272
64332
  let totalOriginal = 0, totalCompressed = 0, toonifiedCount = 0;
64273
64333
  for (const filePath of files) {
64274
64334
  try {
64275
- let markdown = await convertFile(filePath, format);
64335
+ const converted = await convertFile(filePath, format);
64336
+ for (const w of converted.warnings) {
64337
+ process.stderr.write(`Warning (${basename(filePath)}): ${w}
64338
+ `);
64339
+ }
64340
+ batchWarnings += converted.warnings.length;
64341
+ let markdown = converted.markdown;
64276
64342
  if (options.toonify) {
64277
64343
  if (markdown.trim()) {
64278
64344
  if (Buffer.byteLength(markdown, "utf8") > TOONIFY_MAX_INPUT_BYTES) {
@@ -64311,6 +64377,11 @@ async function batchCommand(options) {
64311
64377
  process.stderr.write(`
64312
64378
  Batch complete: ${successful} succeeded, ${failed} failed, ${files.length} total
64313
64379
  `);
64380
+ if (options.strict && batchWarnings > 0) {
64381
+ process.stderr.write(`Error: --strict: conversion produced ${batchWarnings} warning(s) across the batch.
64382
+ `);
64383
+ process.exitCode = 1;
64384
+ }
64314
64385
  }
64315
64386
 
64316
64387
  // src/commands/auth.ts
@@ -64518,8 +64589,8 @@ import { readFileSync as readFileSync5 } from "fs";
64518
64589
  import { resolve, dirname as dirname3 } from "path";
64519
64590
  import { fileURLToPath } from "url";
64520
64591
  var getVersion = () => {
64521
- if ("1.0.29") {
64522
- return "1.0.29";
64592
+ if ("1.0.31") {
64593
+ return "1.0.31";
64523
64594
  }
64524
64595
  try {
64525
64596
  const __dirname2 = dirname3(fileURLToPath(import.meta.url));
@@ -64532,7 +64603,7 @@ var getVersion = () => {
64532
64603
  }
64533
64604
  };
64534
64605
  var VERSION = getVersion();
64535
- program.name("formatferry").version(VERSION).description("Convert HTML, DOCX, PDF, XLSX, CSV, JSON, XML, PPTX to Markdown").option("-i, --input <path>", "Input file path").option("-o, --output <path>", "Output file path").option("-f, --format <format>", "Markdown format (github, commonmark, slack, discord, reddit, confluence, gdocs, rmarkdown)", "github").option("--url <url>", "Fetch article from URL").option("--batch <glob>", "Batch convert files (Premium)").option("--output-dir <dir>", "Output directory for batch").option("--toonify", "Compress output to TOON format (Premium)").option("--offline", "Skip entitlement check (use cached or free tier)").option("--timeout <ms>", "License server timeout in milliseconds (default: 2000)", "2000").action(async (options) => {
64606
+ program.name("formatferry").version(VERSION).description("Convert HTML, DOCX, PDF, XLSX, CSV, JSON, XML, PPTX to Markdown").option("-i, --input <path>", "Input file path").option("-o, --output <path>", "Output file path").option("-f, --format <format>", "Markdown format (github, commonmark, slack, discord, reddit, confluence, gdocs, rmarkdown)", "github").option("--url <url>", "Fetch article from URL").option("--batch <glob>", "Batch convert files (Premium)").option("--output-dir <dir>", "Output directory for batch").option("--toonify", "Compress output to TOON format (Premium)").option("--offline", "Skip entitlement check (use cached or free tier)").option("--timeout <ms>", "License server timeout in milliseconds (default: 2000)", "2000").option("--strict", "Exit with code 1 if the conversion produced any warnings (including advisory ones)").action(async (options) => {
64536
64607
  if (options.batch) {
64537
64608
  await batchCommand(options);
64538
64609
  } else {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "formatferry",
3
- "version": "1.0.29",
3
+ "version": "1.0.31",
4
4
  "description": "FormatFerry CLI — Convert HTML, DOCX, PDF, XLSX, CSV, JSON, XML, PPTX to Markdown",
5
5
  "keywords": [
6
6
  "markdown",