skilldrop-cli 0.16.34 → 0.16.35

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.34",
3
+ "version": "0.16.35",
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,7 +47,7 @@ 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); 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 |
50
+ | `.pdf` | Through `pdftotext`. Tables from aligned columns (told apart by where each word sits on the page, so two columns set close together don't merge), 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "file-to-markdown",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
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": {
@@ -1104,9 +1104,78 @@ def _pdf_page_plain(page):
1104
1104
  return out
1105
1105
 
1106
1106
 
1107
- def _pdf_page_md(page, rep):
1107
+ _BBOX_PAGE = re.compile(r"<page\b.*?</page>", re.S)
1108
+ _BBOX_WORD = re.compile(r'<word xMin="([\d.]+)" yMin="([\d.]+)" xMax="([\d.]+)" yMax="([\d.]+)">(.*?)</word>', re.S)
1109
+
1110
+
1111
+ def _word_rows(bbox_html):
1112
+ """Each physical line of every page as [(xMin, xMax, height, text)], keyed by its words.
1113
+ `pdftotext -bbox-layout` puts each table cell in a block of its own, so words are
1114
+ regrouped by their vertical position into the rows the layout reading shows."""
1115
+ import html as _html
1116
+ rows = {}
1117
+ for page in _BBOX_PAGE.findall(bbox_html):
1118
+ words = [(float(a), float(b), float(c), float(d), _html.unescape(t)) for a, b, c, d, t in _BBOX_WORD.findall(page)]
1119
+ words.sort(key=lambda w: (round(w[3]), w[0]))
1120
+ line, base = [], None
1121
+ for x0, y0, x1, y1, t in words + [(0, 0, 0, -99, "")]:
1122
+ if base is not None and abs(y1 - base) > 2:
1123
+ line.sort()
1124
+ key = " ".join(w[3] for w in line)
1125
+ if key and key not in rows:
1126
+ rows[key] = line
1127
+ line = []
1128
+ base = y1
1129
+ if t:
1130
+ line.append((x0, x1, y1 - y0, t))
1131
+ return rows
1132
+
1133
+
1134
+ def _finer_cells(region, boxes):
1135
+ """The region's rows split again from where each word sits on the page, or None.
1136
+
1137
+ `pdftotext -layout` turns positions into characters, so two columns whose text sits closer
1138
+ than about two characters (a right-aligned amount beside a left-aligned date) come out one
1139
+ space apart and merge into one cell. A gap between words is a column when it is at least
1140
+ 0.6 of the line's height (a word space is about a quarter of it) and, when the table has
1141
+ word spaces to compare with, at least twice the smallest of them, which keeps a monospace
1142
+ font's wide spaces from reading as columns. The new split is used only when every row then
1143
+ has the same number of cells, so a table that already came out right is never changed."""
1144
+ if not boxes:
1145
+ return None
1146
+ found = [boxes.get(" ".join(r.split())) for r in region]
1147
+ if not any(found):
1148
+ return None
1149
+ gaps = []
1150
+ for ws in found:
1151
+ if ws:
1152
+ h = sorted(w[2] for w in ws)[len(ws) // 2]
1153
+ gaps += [(b[0] - a[1], h) for a, b in zip(ws, ws[1:])]
1154
+ small = [g for g, h in gaps if g < 0.6 * h]
1155
+ floor = 2 * min(small) if small else 0
1156
+ rows = []
1157
+ for r, ws in zip(region, found):
1158
+ if not ws:
1159
+ rows.append(_cells(r))
1160
+ continue
1161
+ h = sorted(w[2] for w in ws)[len(ws) // 2]
1162
+ cells, cur = [], [ws[0][3]]
1163
+ for a, b in zip(ws, ws[1:]):
1164
+ g = b[0] - a[1]
1165
+ if g >= 0.6 * h and g >= floor:
1166
+ cells.append(" ".join(cur)); cur = []
1167
+ cur.append(b[3])
1168
+ cells.append(" ".join(cur))
1169
+ rows.append(cells)
1170
+ if len(set(len(c) for c in rows)) != 1 or rows == [_cells(r) for r in region]:
1171
+ return None
1172
+ return rows
1173
+
1174
+
1175
+ def _pdf_page_md(page, rep, fine=None):
1108
1176
  """One page of `pdftotext -layout` text -> Markdown. Layout mode keeps line breaks,
1109
- indentation and column alignment, which is what tables and lists are recovered from."""
1177
+ indentation and column alignment, which is what tables and lists are recovered from.
1178
+ `fine` maps a line's words to where each word sits on the page (see _finer_cells)."""
1110
1179
  lines = page.splitlines()
1111
1180
  out, i, n = [], 0, len(lines)
1112
1181
  while i < n:
@@ -1133,7 +1202,16 @@ def _pdf_page_md(page, rep):
1133
1202
  break
1134
1203
  if rows >= 2:
1135
1204
  widths = set(len(_cells(r)) for r in region)
1136
- if len(widths) == 1 and rows == len(region):
1205
+ finer = _finer_cells(region, fine) if rows == len(region) else None
1206
+ if finer and (len(widths) != 1 or len(finer[0]) > len(_cells(region[0]))):
1207
+ rep.keep("pdf table columns separated by position", 1)
1208
+ cells = [[cell_text(md_escape(c)) for c in r] for r in finer]
1209
+ width = len(cells[0])
1210
+ out.append("| " + " | ".join(cells[0]) + " |")
1211
+ out.append("|" + "---|" * width)
1212
+ out += ["| " + " | ".join(r) + " |" for r in cells[1:]]
1213
+ rep.keep("pdf tables", 1)
1214
+ elif len(widths) == 1 and rows == len(region):
1137
1215
  cells = [[cell_text(md_escape(c)) for c in _cells(r)] for r in region]
1138
1216
  width = len(cells[0])
1139
1217
  out.append("| " + " | ".join(cells[0]) + " |")
@@ -1318,6 +1396,11 @@ def pdf_to_md(path, rep, ocr=True, ocr_lang="eng"):
1318
1396
  elif blank and len(blank) == len(layout):
1319
1397
  sys.exit("error: %s has no text layer (probably scanned), and --no-ocr was given." % path)
1320
1398
  layout = _join_split_tables(layout, rep)
1399
+ # Where each word sits, to tell table columns apart that sit closer than two characters
1400
+ # in the layout reading (see _finer_cells).
1401
+ proc = subprocess.run([exe, "-bbox-layout", "-enc", "UTF-8", str(path), "-"], stdout=subprocess.PIPE,
1402
+ stderr=subprocess.PIPE, check=False)
1403
+ fine = _word_rows(proc.stdout.decode("utf-8", "replace")) if proc.returncode == 0 else {}
1321
1404
  out, plain_pages = [], []
1322
1405
  for n, page in enumerate(layout, 1):
1323
1406
  out += ["<!-- page %d -->" % n, ""]
@@ -1325,7 +1408,7 @@ def pdf_to_md(path, rep, ocr=True, ocr_lang="eng"):
1325
1408
  out += _pdf_page_plain(reading[n - 1])
1326
1409
  plain_pages.append(n)
1327
1410
  else:
1328
- out += _pdf_page_md(page, rep)
1411
+ out += _pdf_page_md(page, rep, fine)
1329
1412
  pages = layout
1330
1413
  if plain_pages:
1331
1414
  rep.notes.append("page(s) %s have side-by-side columns, so they were read in column order with "