skilldrop-cli 0.16.32 → 0.16.33

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "skilldrop-cli",
3
- "version": "0.16.32",
3
+ "version": "0.16.33",
4
4
  "description": "Portable AI-agent skills, measured against the agent without them. ADRs, PRDs, runbooks, decks and reviews, installable into Claude Code, Cursor, Kiro, and any IDE that takes custom instructions. One command per skill or per role-based pack.",
5
5
  "author": {
6
6
  "name": "Sanjay Ananthanarayan",
@@ -47,12 +47,16 @@ or a rewrite that goes back out through `md-to-docx` or `md-to-html`.
47
47
  | `.csv` / `.tsv` | One table, delimiter detected |
48
48
  | `.json` | An array of objects becomes a table; an object becomes a key/value table plus a table per array |
49
49
  | `.txt` / `.md` | Passed through |
50
- | `.pdf` | Through `pdftotext`. Tables from aligned columns, lists from indentation, short standalone lines as `###` headings (guessed, and said so); pages with side-by-side columns are read in column order with nothing inferred. No images |
50
+ | `.pdf` | Through `pdftotext`. Tables from aligned columns, lists from indentation, short standalone lines as `###` headings (guessed, and said so); a table that runs onto the next page is joined into one, without the header the PDF repeats; pages with side-by-side columns are read in column order with nothing inferred. Scanned pages are read by OCR when `tesseract` is installed. No images |
51
51
 
52
52
  3. **PDF without `pdftotext`.** The script stops with install steps (`brew install poppler`
53
53
  on macOS, `sudo apt install poppler-utils` on Debian/Ubuntu). Pass those on; don't try to
54
- read the PDF bytes yourself. A PDF with no text layer is a scan and needs OCR, which this
55
- skill does not do.
54
+ read the PDF bytes yourself. A page with no text layer is a scan. The script reads it by
55
+ OCR when `tesseract` is installed (`brew install tesseract`, `sudo apt install tesseract-ocr`),
56
+ and otherwise stops with those steps. For a scan that isn't in English, pass `--ocr-lang`
57
+ with tesseract's code (`deu`, `fra`, `eng+fra`). OCR misreads characters, so the reply says
58
+ which pages were OCR'd and asks the user to check every number, name and date in them
59
+ against the PDF.
56
60
 
57
61
  4. **Old binary formats** (`.doc`, `.ppt`, `.xls`, `.rtf`, `.pages`, `.key`): the script
58
62
  refuses and names the format to save as. On macOS, `textutil -convert docx file.doc`
@@ -73,7 +77,7 @@ missing, emit `BLOCKED: need <the file | pdftotext on PATH>` and stop.
73
77
 
74
78
  ## Useful references in this skill
75
79
 
76
- - [`scripts/to_markdown.py`](scripts/to_markdown.py): the converter (stdlib only; `pdftotext` optional for PDF)
80
+ - [`scripts/to_markdown.py`](scripts/to_markdown.py): the converter (stdlib only; `pdftotext` optional for PDF, `tesseract` for scanned pages)
77
81
  - [`examples/mixed-inputs.md`](examples/mixed-inputs.md): a Word brief, a deck, a workbook and a web page converted, with the real Markdown and stderr report
78
82
 
79
83
  ## Quality bar
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "file-to-markdown",
3
- "version": "0.1.4",
3
+ "version": "0.2.0",
4
4
  "description": "Convert a Word document, PowerPoint deck, Excel workbook, web page, CSV, JSON, text file or PDF into clean Markdown that keeps headings, lists, tables, links, bold and italic, slide titles and speaker notes, and reports everything it dropped (images, charts, comments, formulas, tracked changes), so the content can be read, reviewed or fed to another skill. Use when the user hands over a .docx, .pptx, .xlsx, .html, .csv, .json or .pdf and says \"read this\", \"turn this into markdown\", \"extract the text\", or wants it critiqued or briefed.",
5
5
  "entrypoint": "SKILL.md",
6
6
  "deps": {
@@ -41,7 +41,16 @@
41
41
  "pdf",
42
42
  "extract"
43
43
  ],
44
- "permissions": {"network": [], "commands": ["pdftotext"], "files": "named-paths", "notes": "pdftotext (Poppler) is used only for .pdf input, and only if it is installed."},
44
+ "permissions": {
45
+ "network": [],
46
+ "commands": [
47
+ "pdftotext",
48
+ "pdftoppm",
49
+ "tesseract"
50
+ ],
51
+ "files": "named-paths",
52
+ "notes": "pdftotext (Poppler) is used only for .pdf input, and only if it is installed. A page with no text layer is rendered with pdftoppm (Poppler) into a temporary folder that is deleted afterwards, and read with tesseract, only if tesseract is installed."
53
+ },
45
54
  "model": {
46
55
  "tier": "light",
47
56
  "rationale": "A deterministic stdlib script does the extraction; the model runs it, relays the dropped-content report and hands off. No generation beyond a short summary of losses."
@@ -2,7 +2,8 @@
2
2
  """Convert a document into clean Markdown that another skill can read.
3
3
 
4
4
  Stdlib only, Python 3.9+. Handles .docx, .pptx, .xlsx, .html/.htm, .csv/.tsv,
5
- .json, .txt/.md, and .pdf (through `pdftotext` when it is installed).
5
+ .json, .txt/.md, and .pdf (through `pdftotext` when it is installed). A PDF page with no
6
+ text layer (a scan) is read by OCR when `tesseract` and `pdftoppm` are installed.
6
7
  Everything the conversion could not carry (images, charts, comments, formulas,
7
8
  tracked changes) is counted and reported on stderr, so nobody mistakes the
8
9
  Markdown for the whole document.
@@ -12,6 +13,7 @@ Usage:
12
13
  python3 to_markdown.py deck.pptx -o deck.md
13
14
  python3 to_markdown.py model.xlsx -o model.md --max-rows 200
14
15
  python3 to_markdown.py page.html -o - # write to stdout
16
+ python3 to_markdown.py scan.pdf -o scan.md --ocr-lang deu # OCR a German scan
15
17
  """
16
18
 
17
19
  import argparse
@@ -22,6 +24,7 @@ import re
22
24
  import shutil
23
25
  import subprocess
24
26
  import sys
27
+ import tempfile
25
28
  import zipfile
26
29
  from collections import OrderedDict
27
30
  from datetime import datetime, timedelta
@@ -1122,8 +1125,9 @@ def _pdf_page_md(page, rep):
1122
1125
  region.append(line); rows += 1; cols |= _col_starts(line); j += 1
1123
1126
  elif line.strip() and _col_starts(line) and _col_starts(line) <= _near(cols):
1124
1127
  region.append(line); j += 1
1125
- elif not line.strip() and j + 1 < n and lines[j + 1].strip() and (
1126
- len(_cells(lines[j + 1])) >= 2 or _col_starts(lines[j + 1]) <= _near(cols)):
1128
+ elif not line.strip() and j + 1 < n and len(_cells(lines[j + 1])) >= 2:
1129
+ # After a blank line only a full row continues the table. A one-cell line
1130
+ # there ("End of report.") is the next paragraph, not a wrapped cell.
1127
1131
  j += 1
1128
1132
  else:
1129
1133
  break
@@ -1193,7 +1197,83 @@ def _pdf_page_md(page, rep):
1193
1197
  return out
1194
1198
 
1195
1199
 
1196
- def pdf_to_md(path, rep):
1200
+ _FURNITURE = re.compile(r"^\s*(page\s+)?\d{1,4}(\s*(of|/)\s*\d{1,4})?\s*$|^\s*[-–—]\s*\d{1,4}\s*[-–—]\s*$", re.I)
1201
+
1202
+
1203
+ def _join_split_tables(pages, rep):
1204
+ """A table that runs off the foot of one page continues at the top of the next, where it
1205
+ would otherwise start a second table whose first data row reads as its header. When a page
1206
+ ends in a table and the next page's first rows (after page numbers and any running header)
1207
+ have the same number of cells, those rows are moved up to finish the table. A header row
1208
+ the PDF repeats on the new page is dropped. Returns the pages, rewritten."""
1209
+ pages = [p.split("\n") for p in pages]
1210
+ first_lines = [next((l.strip() for l in p if l.strip()), "") for p in pages]
1211
+ running = {t for t in first_lines if t and first_lines.count(t) > 1} # a header on several pages
1212
+ holder = None
1213
+ for k in range(len(pages) - 1):
1214
+ a_idx = k if holder is None else holder
1215
+ a, b = pages[a_idx], pages[k + 1]
1216
+ # the last table row on the holder page, before trailing page furniture
1217
+ end = len(a) - 1
1218
+ while end >= 0 and (not a[end].strip() or _FURNITURE.match(a[end])):
1219
+ end -= 1
1220
+ holder = None
1221
+ if end < 0 or len(_cells(a[end])) < 2:
1222
+ continue
1223
+ width = len(_cells(a[end]))
1224
+ start = end
1225
+ while start - 1 >= 0 and (len(_cells(a[start - 1])) == width or
1226
+ (not a[start - 1].strip() and start - 2 >= 0 and len(_cells(a[start - 2])) == width)):
1227
+ start -= 1
1228
+ header = " ".join(a[start].split())
1229
+ # the first content on the next page, past page numbers and a running header
1230
+ i = 0
1231
+ while i < len(b) and (not b[i].strip() or _FURNITURE.match(b[i]) or b[i].strip() in running):
1232
+ i += 1
1233
+ if i >= len(b) or len(_cells(b[i])) != width:
1234
+ continue
1235
+ j = i
1236
+ rows = []
1237
+ while j < len(b) and (len(_cells(b[j])) == width or
1238
+ (not b[j].strip() and j + 1 < len(b) and len(_cells(b[j + 1])) == width)):
1239
+ if b[j].strip():
1240
+ rows.append(b[j])
1241
+ j += 1
1242
+ dropped = bool(rows) and " ".join(rows[0].split()) == header
1243
+ if dropped:
1244
+ rows = rows[1:]
1245
+ if not rows:
1246
+ continue
1247
+ a[end + 1:end + 1] = rows
1248
+ del b[i:j]
1249
+ rep.notes.append("a table continued from page %d onto page %d was joined into one%s" % (
1250
+ a_idx + 1, k + 2, ", and the header row repeated on page %d was dropped" % (k + 2) if dropped else ""))
1251
+ rep.keep("pdf tables joined across pages", 1)
1252
+ if not any(l.strip() and not _FURNITURE.match(l) and l.strip() not in running for l in b):
1253
+ holder = a_idx # the whole page was table: the next page may continue it too
1254
+ return ["\n".join(p) for p in pages]
1255
+
1256
+
1257
+ def _ocr_page(path, n, ppm, tess, lang):
1258
+ """Page n of the PDF as text, by rendering it at 300 dpi and reading it with tesseract.
1259
+ Page segmentation mode 6 reads a uniform block and keeps the spacing between words, which
1260
+ is what the table and list inference reads columns from."""
1261
+ with tempfile.TemporaryDirectory(prefix="to-md-ocr-") as tmp:
1262
+ stem = str(Path(tmp) / "page")
1263
+ r = subprocess.run([ppm, "-r", "300", "-png", "-f", str(n), "-l", str(n), str(path), stem],
1264
+ stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=False)
1265
+ pngs = sorted(Path(tmp).glob("page*.png"))
1266
+ if r.returncode != 0 or not pngs:
1267
+ sys.exit("error: pdftoppm could not render page %d: %s" % (n, r.stderr.decode("utf-8", "replace").strip()))
1268
+ r = subprocess.run([tess, str(pngs[0]), "stdout", "-l", lang, "--psm", "6",
1269
+ "-c", "preserve_interword_spaces=1"],
1270
+ stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=False)
1271
+ if r.returncode != 0:
1272
+ sys.exit("error: tesseract failed on page %d: %s" % (n, r.stderr.decode("utf-8", "replace").strip()))
1273
+ return r.stdout.decode("utf-8", "replace").replace("\f", "")
1274
+
1275
+
1276
+ def pdf_to_md(path, rep, ocr=True, ocr_lang="eng"):
1197
1277
  exe = shutil.which("pdftotext")
1198
1278
  if not exe:
1199
1279
  sys.exit("error: reading PDF needs `pdftotext` (part of Poppler), which is not on PATH.\n"
@@ -1211,9 +1291,33 @@ def pdf_to_md(path, rep):
1211
1291
  return pages[:-1] if pages and not pages[-1].strip() else pages
1212
1292
 
1213
1293
  layout, reading = run(["-layout"]), run([])
1214
- if not "".join(layout).strip():
1215
- sys.exit("error: %s has no text layer (probably scanned). It needs OCR first; "
1216
- "this script does not do OCR." % path)
1294
+ blank = [n for n, p in enumerate(layout, 1) if not p.strip()]
1295
+ if blank and ocr is not False:
1296
+ tess, ppm = shutil.which("tesseract"), shutil.which("pdftoppm")
1297
+ if not (tess and ppm):
1298
+ if len(blank) == len(layout):
1299
+ sys.exit("error: %s has no text layer (probably scanned), so it needs OCR, which uses "
1300
+ "`tesseract`%s.\n"
1301
+ " macOS: brew install tesseract (pdftoppm comes with poppler)\n"
1302
+ " Debian/Ubuntu: sudo apt install tesseract-ocr poppler-utils\n"
1303
+ " Windows: install Tesseract (UB Mannheim build) and add it to PATH\n"
1304
+ "Then rerun. Add --ocr-lang deu (or another installed language) for a non-English scan."
1305
+ % (path, "" if ppm else " and `pdftoppm`"))
1306
+ rep.notes.append("page(s) %s have no text layer and were left empty: install tesseract to read them by OCR"
1307
+ % ", ".join(map(str, blank)))
1308
+ else:
1309
+ for n in blank:
1310
+ text = _ocr_page(path, n, ppm, tess, ocr_lang)
1311
+ layout[n - 1] = text
1312
+ if n <= len(reading):
1313
+ reading[n - 1] = text
1314
+ rep.keep("pages read by OCR", len(blank))
1315
+ rep.notes.append("page(s) %s are scanned, so their text was read by OCR (tesseract, language %s, 300 dpi). "
1316
+ "OCR misreads characters: check every number, name and date against the PDF"
1317
+ % (", ".join(map(str, blank)), ocr_lang))
1318
+ elif blank and len(blank) == len(layout):
1319
+ sys.exit("error: %s has no text layer (probably scanned), and --no-ocr was given." % path)
1320
+ layout = _join_split_tables(layout, rep)
1217
1321
  out, plain_pages = [], []
1218
1322
  for n, page in enumerate(layout, 1):
1219
1323
  out += ["<!-- page %d -->" % n, ""]
@@ -1250,7 +1354,12 @@ def main(argv=None):
1250
1354
  ap.add_argument("-o", "--output", required=True, help="Markdown file to write, or - for stdout")
1251
1355
  ap.add_argument("--max-rows", type=int, default=0,
1252
1356
  help="xlsx only: keep at most this many data rows per sheet (0 = all)")
1357
+ ap.add_argument("--ocr-lang", default="eng",
1358
+ help="pdf only: tesseract language for scanned pages, e.g. eng, deu, fra, eng+fra (default eng)")
1359
+ ap.add_argument("--no-ocr", action="store_true", help="pdf only: never run OCR on scanned pages")
1253
1360
  a = ap.parse_args(argv)
1361
+ if not re.fullmatch(r"[A-Za-z_]{3,}(\+[A-Za-z_]{3,})*", a.ocr_lang):
1362
+ sys.exit("error: --ocr-lang takes tesseract language codes such as eng or eng+fra")
1254
1363
 
1255
1364
  path = Path(a.input)
1256
1365
  if not path.is_file():
@@ -1267,7 +1376,8 @@ def main(argv=None):
1267
1376
  md += "\n"
1268
1377
  elif ext in HANDLERS:
1269
1378
  fn = HANDLERS[ext]
1270
- md = fn(path, rep, a.max_rows) if fn is xlsx_to_md else fn(path, rep)
1379
+ md = (fn(path, rep, a.max_rows) if fn is xlsx_to_md else
1380
+ fn(path, rep, ocr=not a.no_ocr, ocr_lang=a.ocr_lang) if fn is pdf_to_md else fn(path, rep))
1271
1381
  else:
1272
1382
  sys.exit("error: unsupported file type %r. Supported: %s, .txt, .md"
1273
1383
  % (ext, ", ".join(sorted(HANDLERS))))
@@ -66,6 +66,13 @@ has a shaded header row and right-aligned numbers, and the 480 × 240 px chart (
66
66
  DPI) is centred at 3.3 × 1.7 in. The numbered proposals run 1 to 3, the "support dashboard" and
67
67
  "the alias section" links are blue and underlined, and the quote is indented and italic.
68
68
 
69
+ **Opened in Microsoft Word** for Mac 16.114 (2026-10-20), through AppleScript: it opened with
70
+ no repair prompt, the first paragraph has the built-in Heading 1 style, and it holds one table
71
+ and one inline picture. Word's own PDF export of it shows the nested bullets, both task boxes,
72
+ the table with its numbers right-aligned, the code block and the quote, embeds the chart at
73
+ 480 × 240, and carries both links as live link annotations: the dashboard URL and the jump to
74
+ "Retiring the alias".
75
+
69
76
  ---
70
77
 
71
78
  ## Reply to the user
@@ -76,7 +76,12 @@ all well-formed. Row 2 of the first sheet reads back as:
76
76
 
77
77
  The frozen pane is `ySplit="1"` and the autofilter is `A1:G5`. A Quick Look preview shows
78
78
  `$1,240,500.00`, `12.5%` and `2027-03-31` as formatted values. Quick Look leaves boolean cells
79
- blank, which is a limitation of the previewer. Excel and LibreOffice show them as TRUE/FALSE.
79
+ blank, which is a limitation of the previewer.
80
+
81
+ **Opened in Microsoft Excel** for Mac 16.114 (2026-10-20), read through AppleScript without
82
+ saving: three sheets; D2 is the number 1240500 shown as `$1,240,500.00`, E2 the number 0.125
83
+ shown as `12.5%`, F2 a date shown as `2027-03-31`, G2 the boolean `TRUE`, and A2 the text
84
+ `00412` with its zeros. The first sheet has its filter on and its header row frozen.
80
85
 
81
86
  ---
82
87