meltify 0.2.1__tar.gz → 0.2.2__tar.gz

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 (82) hide show
  1. {meltify-0.2.1 → meltify-0.2.2}/PKG-INFO +9 -4
  2. {meltify-0.2.1 → meltify-0.2.2}/README.md +5 -2
  3. {meltify-0.2.1 → meltify-0.2.2}/pyproject.toml +3 -2
  4. {meltify-0.2.1 → meltify-0.2.2}/pyproject.toml.orig +3 -2
  5. {meltify-0.2.1 → meltify-0.2.2}/src/meltify/commands/doctor.py +2 -0
  6. {meltify-0.2.1 → meltify-0.2.2}/src/meltify/commands/read.py +1 -0
  7. {meltify-0.2.1 → meltify-0.2.2}/src/meltify/converters/__init__.py +3 -0
  8. meltify-0.2.2/src/meltify/converters/parquet.py +103 -0
  9. {meltify-0.2.1 → meltify-0.2.2}/src/meltify/converters/run.py +3 -0
  10. meltify-0.2.2/src/meltify/converters/webarchive.py +160 -0
  11. {meltify-0.2.1 → meltify-0.2.2}/src/meltify/data/defaults.toml +2 -0
  12. {meltify-0.2.1 → meltify-0.2.2}/LICENSE +0 -0
  13. {meltify-0.2.1 → meltify-0.2.2}/src/meltify/__init__.py +0 -0
  14. {meltify-0.2.1 → meltify-0.2.2}/src/meltify/__main__.py +0 -0
  15. {meltify-0.2.1 → meltify-0.2.2}/src/meltify/cli.py +0 -0
  16. {meltify-0.2.1 → meltify-0.2.2}/src/meltify/commands/__init__.py +0 -0
  17. {meltify-0.2.1 → meltify-0.2.2}/src/meltify/commands/brief.py +0 -0
  18. {meltify-0.2.1 → meltify-0.2.2}/src/meltify/commands/check.py +0 -0
  19. {meltify-0.2.1 → meltify-0.2.2}/src/meltify/commands/hidden.py +0 -0
  20. {meltify-0.2.1 → meltify-0.2.2}/src/meltify/commands/media.py +0 -0
  21. {meltify-0.2.1 → meltify-0.2.2}/src/meltify/commands/ocr.py +0 -0
  22. {meltify-0.2.1 → meltify-0.2.2}/src/meltify/commands/submit.py +0 -0
  23. {meltify-0.2.1 → meltify-0.2.2}/src/meltify/config.py +0 -0
  24. {meltify-0.2.1 → meltify-0.2.2}/src/meltify/converters/archive.py +0 -0
  25. {meltify-0.2.1 → meltify-0.2.2}/src/meltify/converters/blips.py +0 -0
  26. {meltify-0.2.1 → meltify-0.2.2}/src/meltify/converters/doc.py +0 -0
  27. {meltify-0.2.1 → meltify-0.2.2}/src/meltify/converters/embeds.py +0 -0
  28. {meltify-0.2.1 → meltify-0.2.2}/src/meltify/converters/epub.py +0 -0
  29. {meltify-0.2.1 → meltify-0.2.2}/src/meltify/converters/fallback.py +0 -0
  30. {meltify-0.2.1 → meltify-0.2.2}/src/meltify/converters/hwp.py +0 -0
  31. {meltify-0.2.1 → meltify-0.2.2}/src/meltify/converters/iwa.py +0 -0
  32. {meltify-0.2.1 → meltify-0.2.2}/src/meltify/converters/iwork.py +0 -0
  33. {meltify-0.2.1 → meltify-0.2.2}/src/meltify/converters/kakao.py +0 -0
  34. {meltify-0.2.1 → meltify-0.2.2}/src/meltify/converters/limits.py +0 -0
  35. {meltify-0.2.1 → meltify-0.2.2}/src/meltify/converters/mail.py +0 -0
  36. {meltify-0.2.1 → meltify-0.2.2}/src/meltify/converters/mbox.py +0 -0
  37. {meltify-0.2.1 → meltify-0.2.2}/src/meltify/converters/metafile.py +0 -0
  38. {meltify-0.2.1 → meltify-0.2.2}/src/meltify/converters/msg.py +0 -0
  39. {meltify-0.2.1 → meltify-0.2.2}/src/meltify/converters/notebook.py +0 -0
  40. {meltify-0.2.1 → meltify-0.2.2}/src/meltify/converters/odf.py +0 -0
  41. {meltify-0.2.1 → meltify-0.2.2}/src/meltify/converters/office.py +0 -0
  42. {meltify-0.2.1 → meltify-0.2.2}/src/meltify/converters/ooxml.py +0 -0
  43. {meltify-0.2.1 → meltify-0.2.2}/src/meltify/converters/ooxml_charts.py +0 -0
  44. {meltify-0.2.1 → meltify-0.2.2}/src/meltify/converters/pdf.py +0 -0
  45. {meltify-0.2.1 → meltify-0.2.2}/src/meltify/converters/ppt.py +0 -0
  46. {meltify-0.2.1 → meltify-0.2.2}/src/meltify/converters/quicklook.py +0 -0
  47. {meltify-0.2.1 → meltify-0.2.2}/src/meltify/converters/raster.py +0 -0
  48. {meltify-0.2.1 → meltify-0.2.2}/src/meltify/converters/render.py +0 -0
  49. {meltify-0.2.1 → meltify-0.2.2}/src/meltify/converters/rtf.py +0 -0
  50. {meltify-0.2.1 → meltify-0.2.2}/src/meltify/converters/sheet.py +0 -0
  51. {meltify-0.2.1 → meltify-0.2.2}/src/meltify/converters/slack.py +0 -0
  52. {meltify-0.2.1 → meltify-0.2.2}/src/meltify/converters/sqlite.py +0 -0
  53. {meltify-0.2.1 → meltify-0.2.2}/src/meltify/converters/subtitle.py +0 -0
  54. {meltify-0.2.1 → meltify-0.2.2}/src/meltify/converters/svg.py +0 -0
  55. {meltify-0.2.1 → meltify-0.2.2}/src/meltify/converters/tables.py +0 -0
  56. {meltify-0.2.1 → meltify-0.2.2}/src/meltify/converters/text.py +0 -0
  57. {meltify-0.2.1 → meltify-0.2.2}/src/meltify/converters/unlock.py +0 -0
  58. {meltify-0.2.1 → meltify-0.2.2}/src/meltify/converters/web.py +0 -0
  59. {meltify-0.2.1 → meltify-0.2.2}/src/meltify/converters/wordperfect.py +0 -0
  60. {meltify-0.2.1 → meltify-0.2.2}/src/meltify/converters/xmlsafe.py +0 -0
  61. {meltify-0.2.1 → meltify-0.2.2}/src/meltify/data/__init__.py +0 -0
  62. {meltify-0.2.1 → meltify-0.2.2}/src/meltify/data/brief_en.md +0 -0
  63. {meltify-0.2.1 → meltify-0.2.2}/src/meltify/data/brief_ko.md +0 -0
  64. {meltify-0.2.1 → meltify-0.2.2}/src/meltify/engines/__init__.py +0 -0
  65. {meltify-0.2.1 → meltify-0.2.2}/src/meltify/engines/asr.py +0 -0
  66. {meltify-0.2.1 → meltify-0.2.2}/src/meltify/engines/consensus.py +0 -0
  67. {meltify-0.2.1 → meltify-0.2.2}/src/meltify/engines/ocr.py +0 -0
  68. {meltify-0.2.1 → meltify-0.2.2}/src/meltify/evidence.py +0 -0
  69. {meltify-0.2.1 → meltify-0.2.2}/src/meltify/fetch.py +0 -0
  70. {meltify-0.2.1 → meltify-0.2.2}/src/meltify/ffmpeg.py +0 -0
  71. {meltify-0.2.1 → meltify-0.2.2}/src/meltify/files.py +0 -0
  72. {meltify-0.2.1 → meltify-0.2.2}/src/meltify/forensics/__init__.py +0 -0
  73. {meltify-0.2.1 → meltify-0.2.2}/src/meltify/forensics/pdf.py +0 -0
  74. {meltify-0.2.1 → meltify-0.2.2}/src/meltify/imaging.py +0 -0
  75. {meltify-0.2.1 → meltify-0.2.2}/src/meltify/lang.py +0 -0
  76. {meltify-0.2.1 → meltify-0.2.2}/src/meltify/llm.py +0 -0
  77. {meltify-0.2.1 → meltify-0.2.2}/src/meltify/needs.py +0 -0
  78. {meltify-0.2.1 → meltify-0.2.2}/src/meltify/output.py +0 -0
  79. {meltify-0.2.1 → meltify-0.2.2}/src/meltify/passwords.py +0 -0
  80. {meltify-0.2.1 → meltify-0.2.2}/src/meltify/recognize.py +0 -0
  81. {meltify-0.2.1 → meltify-0.2.2}/src/meltify/safe.py +0 -0
  82. {meltify-0.2.1 → meltify-0.2.2}/src/meltify/tools.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: meltify
3
- Version: 0.2.1
3
+ Version: 0.2.2
4
4
  Summary: Melt files of any format into prompt-ready text where every line cites its source
5
5
  Keywords: llm,prompt,ocr,pdf,transcription,claude-code,agent-skills
6
6
  Author: jeon-jihyeon
@@ -17,7 +17,7 @@ Requires-Dist: ocrmac>=1.0.1 ; sys_platform == 'darwin'
17
17
  Requires-Dist: trafilatura>=2
18
18
  Requires-Dist: python-hwpx>=1
19
19
  Requires-Dist: striprtf>=0.0.29
20
- Requires-Dist: meltify[office,media,asr-mlx,archive,crypto] ; extra == 'all'
20
+ Requires-Dist: meltify[office,media,asr-mlx,archive,parquet,crypto] ; extra == 'all'
21
21
  Requires-Dist: libarchive-c>=5 ; extra == 'archive'
22
22
  Requires-Dist: mlx-whisper>=0.4 ; platform_machine == 'arm64' and sys_platform == 'darwin' and extra == 'asr-mlx'
23
23
  Requires-Dist: msoffcrypto-tool>=6 ; extra == 'crypto'
@@ -32,6 +32,7 @@ Requires-Dist: python-calamine>=0.4 ; extra == 'office'
32
32
  Requires-Dist: legacy-doc>=0.2 ; extra == 'office'
33
33
  Requires-Dist: olefile>=0.47 ; extra == 'office'
34
34
  Requires-Dist: metafile-render>=0.3 ; extra == 'office'
35
+ Requires-Dist: pyarrow>=17 ; extra == 'parquet'
35
36
  Requires-Dist: playwright>=1.50 ; extra == 'render'
36
37
  Requires-Python: >=3.11.9
37
38
  Project-URL: Repository, https://github.com/jeon-jihyeon/meltify
@@ -43,6 +44,7 @@ Provides-Extra: iwork
43
44
  Provides-Extra: media
44
45
  Provides-Extra: ocr-paddle
45
46
  Provides-Extra: office
47
+ Provides-Extra: parquet
46
48
  Provides-Extra: render
47
49
  Description-Content-Type: text/markdown
48
50
 
@@ -99,8 +101,8 @@ Each command has a skill of the same name, such as `meltify-ocr`, that tells the
99
101
 
100
102
  | Group | Formats | Needs |
101
103
  |---|---|---|
102
- | Documents | PDF, Word `.docx` `.docm` `.dotx` `.dotm` `.doc` `.dot`, HWP and HWPX, RTF, ODT, Pages, WordPerfect, EPUB, XPS, FictionBook, MOBI, HTML, Markdown and plain text | `office` for Word and EPUB |
103
- | Spreadsheets | Excel `.xlsx` `.xlsm` `.xltx` `.xltm` `.xlsb` `.xls` `.xlt`, Hancom `.cell`, ODS, Numbers, CSV and TSV | `office` for `.xlsb`, `.xls` and `.xlt`, `iwork` for Numbers |
104
+ | Documents | PDF, Word `.docx` `.docm` `.dotx` `.dotm` `.doc` `.dot`, HWP and HWPX, RTF, ODT, Pages, WordPerfect, EPUB, XPS, FictionBook, MOBI, HTML and Safari `.webarchive`, Markdown and plain text | `office` for Word and EPUB |
105
+ | Spreadsheets | Excel `.xlsx` `.xlsm` `.xltx` `.xltm` `.xlsb` `.xls` `.xlt`, Hancom `.cell`, ODS, Numbers, Parquet, CSV and TSV | `office` for `.xlsb`, `.xls` and `.xlt`, `iwork` for Numbers, `parquet` for Parquet |
104
106
  | Slides | PowerPoint `.pptx` `.pptm` `.potx` `.potm` `.ppsx` `.ppsm` `.ppt` `.pps` `.pot`, Hancom `.show`, ODP, Keynote | `office` for PowerPoint |
105
107
  | Drawings | ODG, SVG and `.svgz`, EMF and WMF with `.emz` and `.wmz`, comic book `.cbz` | |
106
108
  | Mail and chat | `.eml`, `.msg`, mbox, KakaoTalk exports, Slack export zips | `office` for `.msg` |
@@ -186,6 +188,7 @@ Optional parts install on request with `meltify doctor --install NAME`:
186
188
  | `office` | Word, PowerPoint, Outlook `.msg` and EPUB, legacy `.doc`, `.ppt`, `.xls` and `.xlsb`, faster `.xlsx` reading through calamine, and drawing EMF pictures without LibreOffice |
187
189
  | `archive` | 7z and rar through the system libarchive, plus a pinned 7-Zip download for encrypted ones |
188
190
  | `iwork` | Numbers tables. Pages and Keynote need nothing extra |
191
+ | `parquet` | Parquet files, the first 200 rows and per-column min, max and null counts |
189
192
  | `crypto` | encrypted Office files, AES zips, and password-protected HWP and iWork files |
190
193
  | `render` | `read --render` for JavaScript-heavy pages, using your Chrome or a downloaded Chromium |
191
194
  | `media` | video URLs through yt-dlp |
@@ -208,6 +211,7 @@ See [examples/meltify.toml](examples/meltify.toml) for a sample. A few keys wort
208
211
 
209
212
  - `lang` is the language OCR expects (`ko` by default)
210
213
  - `asr.lang` is the spoken language for speech engines. It's `auto` by default, separate from `lang`, so Whisper detects each recording's language instead of translating it
214
+ - `read.parquet_rows` sets how many Parquet rows melt per file
211
215
  - `render.quicklook = false` keeps Quick Look and Spotlight out of every render
212
216
 
213
217
  ## Limits
@@ -219,6 +223,7 @@ See [examples/meltify.toml](examples/meltify.toml) for a sample. A few keys wort
219
223
  - Rendered rows, EMF bitmaps and Quick Look previews are only as good as the OCR that reads them, and Quick Look exists only on macOS
220
224
  - Pages and Keynote charts are listed in `needs` instead of read, and a Pages or Keynote file with no text records falls back to its embedded preview image
221
225
  - DRM-protected HWP files, password-protected WordPerfect files, and Hancom `.cell` and `.show` files from before 2014 and Hanshow `.hpt` files, which are binary, aren't read. Save the Hancom ones again as `.xlsx` or `.pptx`
226
+ - Parquet files show their first 200 rows, and the rest are counted in `needs`
222
227
  - Encrypted 7z and rar need 7-Zip, which `meltify doctor --install archive` downloads. Plain ones open with the system libarchive, a separate package on Linux, with 7-Zip, or with `bsdtar` on macOS
223
228
  - The plugin launcher is a POSIX shell script and `doctor --install` only downloads macOS and Linux builds, so Windows isn't supported
224
229
 
@@ -51,8 +51,8 @@ Each command has a skill of the same name, such as `meltify-ocr`, that tells the
51
51
 
52
52
  | Group | Formats | Needs |
53
53
  |---|---|---|
54
- | Documents | PDF, Word `.docx` `.docm` `.dotx` `.dotm` `.doc` `.dot`, HWP and HWPX, RTF, ODT, Pages, WordPerfect, EPUB, XPS, FictionBook, MOBI, HTML, Markdown and plain text | `office` for Word and EPUB |
55
- | Spreadsheets | Excel `.xlsx` `.xlsm` `.xltx` `.xltm` `.xlsb` `.xls` `.xlt`, Hancom `.cell`, ODS, Numbers, CSV and TSV | `office` for `.xlsb`, `.xls` and `.xlt`, `iwork` for Numbers |
54
+ | Documents | PDF, Word `.docx` `.docm` `.dotx` `.dotm` `.doc` `.dot`, HWP and HWPX, RTF, ODT, Pages, WordPerfect, EPUB, XPS, FictionBook, MOBI, HTML and Safari `.webarchive`, Markdown and plain text | `office` for Word and EPUB |
55
+ | Spreadsheets | Excel `.xlsx` `.xlsm` `.xltx` `.xltm` `.xlsb` `.xls` `.xlt`, Hancom `.cell`, ODS, Numbers, Parquet, CSV and TSV | `office` for `.xlsb`, `.xls` and `.xlt`, `iwork` for Numbers, `parquet` for Parquet |
56
56
  | Slides | PowerPoint `.pptx` `.pptm` `.potx` `.potm` `.ppsx` `.ppsm` `.ppt` `.pps` `.pot`, Hancom `.show`, ODP, Keynote | `office` for PowerPoint |
57
57
  | Drawings | ODG, SVG and `.svgz`, EMF and WMF with `.emz` and `.wmz`, comic book `.cbz` | |
58
58
  | Mail and chat | `.eml`, `.msg`, mbox, KakaoTalk exports, Slack export zips | `office` for `.msg` |
@@ -138,6 +138,7 @@ Optional parts install on request with `meltify doctor --install NAME`:
138
138
  | `office` | Word, PowerPoint, Outlook `.msg` and EPUB, legacy `.doc`, `.ppt`, `.xls` and `.xlsb`, faster `.xlsx` reading through calamine, and drawing EMF pictures without LibreOffice |
139
139
  | `archive` | 7z and rar through the system libarchive, plus a pinned 7-Zip download for encrypted ones |
140
140
  | `iwork` | Numbers tables. Pages and Keynote need nothing extra |
141
+ | `parquet` | Parquet files, the first 200 rows and per-column min, max and null counts |
141
142
  | `crypto` | encrypted Office files, AES zips, and password-protected HWP and iWork files |
142
143
  | `render` | `read --render` for JavaScript-heavy pages, using your Chrome or a downloaded Chromium |
143
144
  | `media` | video URLs through yt-dlp |
@@ -160,6 +161,7 @@ See [examples/meltify.toml](examples/meltify.toml) for a sample. A few keys wort
160
161
 
161
162
  - `lang` is the language OCR expects (`ko` by default)
162
163
  - `asr.lang` is the spoken language for speech engines. It's `auto` by default, separate from `lang`, so Whisper detects each recording's language instead of translating it
164
+ - `read.parquet_rows` sets how many Parquet rows melt per file
163
165
  - `render.quicklook = false` keeps Quick Look and Spotlight out of every render
164
166
 
165
167
  ## Limits
@@ -171,6 +173,7 @@ See [examples/meltify.toml](examples/meltify.toml) for a sample. A few keys wort
171
173
  - Rendered rows, EMF bitmaps and Quick Look previews are only as good as the OCR that reads them, and Quick Look exists only on macOS
172
174
  - Pages and Keynote charts are listed in `needs` instead of read, and a Pages or Keynote file with no text records falls back to its embedded preview image
173
175
  - DRM-protected HWP files, password-protected WordPerfect files, and Hancom `.cell` and `.show` files from before 2014 and Hanshow `.hpt` files, which are binary, aren't read. Save the Hancom ones again as `.xlsx` or `.pptx`
176
+ - Parquet files show their first 200 rows, and the rest are counted in `needs`
174
177
  - Encrypted 7z and rar need 7-Zip, which `meltify doctor --install archive` downloads. Plain ones open with the system libarchive, a separate package on Linux, with 7-Zip, or with `bsdtar` on macOS
175
178
  - The plugin launcher is a POSIX shell script and `doctor --install` only downloads macOS and Linux builds, so Windows isn't supported
176
179
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "meltify"
3
- version = "0.2.1"
3
+ version = "0.2.2"
4
4
  description = "Melt files of any format into prompt-ready text where every line cites its source"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11.9"
@@ -47,6 +47,7 @@ crypto = [
47
47
  ]
48
48
  archive = ["libarchive-c>=5"]
49
49
  iwork = ["numbers-parser>=4"]
50
+ parquet = ["pyarrow>=17"]
50
51
  render = ["playwright>=1.50"]
51
52
  media = ["yt-dlp>=2026.8.19"]
52
53
  asr-mlx = ["mlx-whisper>=0.4; sys_platform == 'darwin' and platform_machine == 'arm64'"]
@@ -54,7 +55,7 @@ ocr-paddle = [
54
55
  "paddleocr>=3.2",
55
56
  "paddlepaddle>=3.2",
56
57
  ]
57
- all = ["meltify[office,media,asr-mlx,archive,crypto]"]
58
+ all = ["meltify[office,media,asr-mlx,archive,parquet,crypto]"]
58
59
 
59
60
  [project.urls]
60
61
  Repository = "https://github.com/jeon-jihyeon/meltify"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "meltify"
3
- version = "0.2.1"
3
+ version = "0.2.2"
4
4
  description = "Melt files of any format into prompt-ready text where every line cites its source"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11.9"
@@ -35,11 +35,12 @@ crypto = ["msoffcrypto-tool>=6", "pyzipper>=0.4", "cryptography>=42"]
35
35
  archive = ["libarchive-c>=5"]
36
36
  # Only Numbers needs it, since Pages and Keynote are read natively
37
37
  iwork = ["numbers-parser>=4"]
38
+ parquet = ["pyarrow>=17"]
38
39
  render = ["playwright>=1.50"]
39
40
  media = ["yt-dlp>=2026.8.19"]
40
41
  asr-mlx = ["mlx-whisper>=0.4; sys_platform == 'darwin' and platform_machine == 'arm64'"]
41
42
  ocr-paddle = ["paddleocr>=3.2", "paddlepaddle>=3.2"]
42
- all = ["meltify[office,media,asr-mlx,archive,crypto]"]
43
+ all = ["meltify[office,media,asr-mlx,archive,parquet,crypto]"]
43
44
 
44
45
  [project.urls]
45
46
  Repository = "https://github.com/jeon-jihyeon/meltify"
@@ -47,6 +47,7 @@ MODULES = [
47
47
  ("pyzipper", "read aes zip", "meltify doctor --install crypto", False),
48
48
  ("cryptography", "read encrypted hwp iwork", "meltify doctor --install crypto", False),
49
49
  ("numbers_parser", "read numbers", "meltify doctor --install iwork", False),
50
+ ("pyarrow", "read parquet", "meltify doctor --install parquet", False),
50
51
  ("playwright", "read --render", "meltify doctor --install render", False),
51
52
  ("yt_dlp", "media urls", "meltify doctor --install media", False),
52
53
  ("paddleocr", "ocr engine paddle", "meltify doctor --install ocr-paddle", False),
@@ -78,6 +79,7 @@ EXTRAS = [
78
79
  "office",
79
80
  "archive",
80
81
  "iwork",
82
+ "parquet",
81
83
  "crypto",
82
84
  "render",
83
85
  "media",
@@ -628,6 +628,7 @@ def _run(args: argparse.Namespace, settings: dict[str, Any], password: str | Non
628
628
  password=given_password(args, env.warnings.append, password),
629
629
  fallback=bool(conf.get("fallback", True)),
630
630
  shallow=args.shallow,
631
+ parquet_rows=int(conf.get("parquet_rows", 200)),
631
632
  quicklook=looks,
632
633
  temps=temps,
633
634
  )
@@ -346,6 +346,7 @@ register("media", f"{_HERE}:audio", *AUDIO)
346
346
  register("media", f"{_HERE}:video", *VIDEO)
347
347
  register("office", f"{_HERE}.office:convert", *OFFICE)
348
348
  register("web", f"{_HERE}.web:convert", ".html", ".htm", ".xhtml")
349
+ register("webarchive", f"{_HERE}.webarchive:convert", ".webarchive")
349
350
  register("epub", f"{_HERE}.epub:convert", ".epub")
350
351
  register(
351
352
  "archive",
@@ -372,6 +373,7 @@ register("wordperfect", f"{_HERE}.wordperfect:convert", ".wpd", ".wp", ".wp5", "
372
373
  register("notebook", f"{_HERE}.notebook:convert", ".ipynb")
373
374
  register("mbox", f"{_HERE}.mbox:convert", ".mbox")
374
375
  register("sqlite", f"{_HERE}.sqlite:convert", ".db", ".sqlite", ".sqlite3")
376
+ register("parquet", f"{_HERE}.parquet:convert", ".parquet")
375
377
  # Registered after TEXT, so these take over .srt and .vtt from the plain text entry
376
378
  register("subtitle", f"{_HERE}.subtitle:convert", ".srt", ".vtt", ".ass")
377
379
  register("text", f"{_HERE}.text:convert_card", ".vcf", ".ics")
@@ -383,6 +385,7 @@ register_sniff("slack", f"{_HERE}.slack:convert", f"{_HERE}.slack:sniff", {".zip
383
385
  register_sniff("pdf", f"{_HERE}.pdf:convert", _magic(b"%PDF-"))
384
386
  register_sniff("sqlite", f"{_HERE}.sqlite:convert", _magic(b"SQLite format 3\x00"))
385
387
  register_sniff("rtf", f"{_HERE}.rtf:convert", _magic(b"{\\rtf"))
388
+ register_sniff("parquet", f"{_HERE}.parquet:convert", _magic(b"PAR1"))
386
389
  register_sniff("wordperfect", f"{_HERE}.wordperfect:convert", _wordperfect)
387
390
  # Document zips before the archive sniff, so an unnamed one isn't unpacked as a plain zip
388
391
  for kind, target, families in (
@@ -0,0 +1,103 @@
1
+ """Parquet tables: the schema, a per-column profile, then the first rows cited by row number
2
+
3
+ A cite like `a.parquet:7` points at the seventh data row, counted from 1
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from dataclasses import replace
9
+ from pathlib import Path
10
+
11
+ from meltify.converters import Block, Converted, run
12
+ from meltify.converters.limits import MAX_MEMBER_BYTES, human_bytes
13
+ from meltify.converters.tables import sample_cell
14
+ from meltify.evidence import Src
15
+ from meltify.needs import not_read
16
+ from meltify.safe import MissingTool
17
+
18
+
19
+ def _groups(meta, limit: int) -> tuple[list[int], str | None]:
20
+ """Leading row groups that hold the first `limit` rows, and why they stop short if they do
21
+
22
+ Arrow unpacks a whole page at a time, and a few KB of zstd can declare one value of
23
+ hundreds of MB, so the sizes the footer declares are checked before anything is read
24
+ """
25
+ groups: list[int] = []
26
+ rows = size = 0
27
+ for i in range(meta.num_row_groups):
28
+ if rows >= limit:
29
+ break
30
+ group = meta.row_group(i)
31
+ columns = sum(group.column(j).total_uncompressed_size for j in range(group.num_columns))
32
+ size += max(group.total_byte_size, columns)
33
+ if size > MAX_MEMBER_BYTES:
34
+ past = human_bytes(MAX_MEMBER_BYTES)
35
+ return groups, f"row group {i + 1} not read, it unpacks past {past}"
36
+ groups.append(i)
37
+ rows += group.num_rows
38
+ return groups, None
39
+
40
+
41
+ def _head(f, limit: int, groups: list[int]) -> list[tuple]:
42
+ rows: list[tuple] = []
43
+ # Batches end at row group boundaries, so one batch may hold fewer rows than asked
44
+ for batch in f.iter_batches(batch_size=limit, row_groups=groups):
45
+ # Tuples, not dicts, so two columns with the same name both survive
46
+ rows += list(zip(*(c.to_pylist() for c in batch.columns), strict=True))
47
+ if len(rows) >= limit:
48
+ break
49
+ return rows[:limit]
50
+
51
+
52
+ def _profile(meta) -> str:
53
+ """Min, max and nulls per column from the footer statistics, without reading any data
54
+
55
+ A row group that wrote no statistics leaves that column's cells blank, since the rest
56
+ can't speak for it
57
+ """
58
+ lines = ["| column | min | max | nulls |", "|---|---|---|---|"]
59
+ for j in range(meta.num_columns):
60
+ stats = [meta.row_group(i).column(j).statistics for i in range(meta.num_row_groups)]
61
+ low = high = nulls = ""
62
+ if stats and all(s is not None and s.has_min_max for s in stats):
63
+ low, high = min(s.min for s in stats), max(s.max for s in stats)
64
+ if stats and all(s is not None and s.has_null_count for s in stats):
65
+ nulls = f"{sum(s.null_count for s in stats):,}"
66
+ name = meta.schema.column(j).path
67
+ cells = " | ".join(map(sample_cell, (name, low, high)))
68
+ lines.append(f"| {cells} | {nulls} |")
69
+ return "\n".join(lines)
70
+
71
+
72
+ def convert(path: Path, src: Src) -> Converted:
73
+ try:
74
+ import pyarrow.parquet as pq
75
+ except ImportError as e:
76
+ raise MissingTool("pyarrow", "meltify doctor --install parquet") from e
77
+
78
+ out = Converted("parquet")
79
+ with pq.ParquetFile(path) as f:
80
+ total = f.metadata.num_rows
81
+ fields = list(f.schema_arrow)
82
+ schema = "\n".join(f"{field.name}: {field.type}" for field in fields)
83
+ out.blocks.append(Block(src, f"{total:,} rows\n{schema}"))
84
+ if f.metadata.num_row_groups:
85
+ out.blocks.append(Block(src, _profile(f.metadata)))
86
+ limit = run.current().parquet_rows or total
87
+ groups, cut = _groups(f.metadata, limit)
88
+ rows = _head(f, limit, groups) if groups else []
89
+
90
+ if rows:
91
+ lines = [
92
+ "| row | " + " | ".join(sample_cell(field.name) for field in fields) + " |",
93
+ "|---" * (len(fields) + 1) + "|",
94
+ ]
95
+ for n, row in enumerate(rows, start=1):
96
+ lines.append(f"| {n} | " + " | ".join(sample_cell(v) for v in row) + " |")
97
+ out.blocks.append(Block(replace(src, line=1), "\n".join(lines)))
98
+ if (rest := total - len(rows)) > 0:
99
+ shown = f"first {len(rows):,} shown, read.parquet_rows sets how many"
100
+ out.needs.append(not_read(rest, "more row", shown))
101
+ if cut:
102
+ out.needs.append(cut)
103
+ return out
@@ -71,6 +71,9 @@ class RunContext:
71
71
  fallback: bool = True # read.fallback
72
72
  # read --shallow, which starts no renderer or subprocess that only feeds OCR
73
73
  shallow: bool = False
74
+ # read.parquet_rows, 0 for every row. A data file can hold billions of rows, so only the
75
+ # head is melted and the rest counted
76
+ parquet_rows: int = 200
74
77
  quicklook: bool = True # render.quicklook
75
78
  # State the run's workers share, which two runs with the same settings still don't
76
79
  budget: Budget = field(default_factory=Budget, compare=False)
@@ -0,0 +1,160 @@
1
+ """Safari web archives: the saved page through the web extractor, its images through OCR
2
+
3
+ A webarchive is a property list holding the page bytes, every subresource the page
4
+ loaded, and one nested archive per frame
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import hashlib
10
+ import mimetypes
11
+ import plistlib
12
+ import posixpath
13
+ import tempfile
14
+ from pathlib import Path
15
+ from urllib.parse import urldefrag, urljoin, urlparse
16
+
17
+ from meltify.converters import Child, Converted
18
+ from meltify.converters.embeds import Embeds
19
+ from meltify.evidence import Src
20
+ from meltify.needs import not_read
21
+
22
+ HTML = {"text/html", "application/xhtml+xml"}
23
+ # Distinct frames melted per archive, the rest listed in needs
24
+ MAX_FRAMES = 100
25
+ # Frame bytes per byte of the archive. Frames are slices of the archive, so unless they
26
+ # share bytes they can't add up to more than it
27
+ FRAME_RATIO = 2
28
+
29
+
30
+ def _name(resource: dict, fallback: str) -> str:
31
+ """A file name that keeps a suffix, so the registry can melt the resource as a child"""
32
+ name = posixpath.basename(urlparse(resource.get("WebResourceURL") or "").path) or fallback
33
+ if not Path(name).suffix:
34
+ name += mimetypes.guess_extension(resource.get("WebResourceMIMEType") or "") or ""
35
+ return name
36
+
37
+
38
+ def _image(resource: object) -> bytes | None:
39
+ # Stylesheets, scripts and fonts carry no text worth citing
40
+ if not isinstance(resource, dict):
41
+ return None
42
+ data = resource.get("WebResourceData")
43
+ image = str(resource.get("WebResourceMIMEType")).startswith("image/")
44
+ return data if image and isinstance(data, bytes) else None
45
+
46
+
47
+ def _utf8(data: bytes, encoding: str | None) -> bytes:
48
+ # Safari records the encoding the page was served with, which may disagree with its meta
49
+ # tag, and a UTF-8 byte order mark outranks the meta tag in the HTML parser
50
+ if not encoding:
51
+ return data
52
+ try:
53
+ text = data.decode(encoding, errors="replace")
54
+ except LookupError:
55
+ return data
56
+ return b"\xef\xbb\xbf" + text.removeprefix("\ufeff").encode("utf-8")
57
+
58
+
59
+ def _page(resource: dict, src: Src, images: dict[str, tuple[str, bytes]], used: set[str]):
60
+ """The page, with the pictures it references loaded from the archive"""
61
+ from meltify.converters.web import convert_page
62
+
63
+ base = resource.get("WebResourceURL") or ""
64
+
65
+ def load(ref: str, embeds: Embeds) -> tuple[str, bytes] | str:
66
+ url = urldefrag(urljoin(base, ref)).url
67
+ if url not in images:
68
+ return "remote image"
69
+ used.add(url)
70
+ return images[url]
71
+
72
+ data = _utf8(resource["WebResourceData"], resource.get("WebResourceTextEncodingName"))
73
+ with tempfile.TemporaryDirectory(prefix="meltify-webarchive-") as tmp:
74
+ page = Path(tmp) / "page.html"
75
+ page.write_bytes(data)
76
+ # A saved page keeps navigation and footers, as a saved .html does
77
+ out = convert_page(page, src, whole=True, load=load)
78
+ out.kind = "webarchive"
79
+ return out
80
+
81
+
82
+ def convert(path: Path, src: Src) -> Converted:
83
+ raw = path.read_bytes()
84
+ try:
85
+ archive = plistlib.loads(raw)
86
+ except ValueError as e:
87
+ return Converted("webarchive", needs=[f"unreadable webarchive: {e}"[:200]])
88
+ main = archive.get("WebMainResource") if isinstance(archive, dict) else None
89
+ if not isinstance(main, dict) or not isinstance(main.get("WebResourceData"), bytes):
90
+ return Converted("webarchive", needs=["webarchive has no main resource"])
91
+
92
+ embeds = Embeds()
93
+ images: dict[str, tuple[str, bytes]] = {}
94
+ for i, res in enumerate(archive.get("WebSubresources") or [], start=1):
95
+ data = _image(res)
96
+ if data is None:
97
+ continue
98
+ # Every picture is already in memory with the plist, so only the size limit applies
99
+ # here, and the page's references share each one under the picture total
100
+ if why := embeds.refusal(len(data)):
101
+ embeds.skipped[why] += 1
102
+ continue
103
+ images[urldefrag(res.get("WebResourceURL") or f"#{i}").url] = (
104
+ _name(res, f"image{i}"),
105
+ data,
106
+ )
107
+
108
+ used: set[str] = set()
109
+ if (main.get("WebResourceMIMEType") or "text/html") in HTML:
110
+ out = _page(main, src, images, used)
111
+ else:
112
+ # Safari also saves a bare image or PDF this way, so it melts like the file itself
113
+ out = Converted("webarchive")
114
+ out.children.append(Child(_name(main, "main"), src, data=main["WebResourceData"]))
115
+ # Pictures only a stylesheet or script loads, cited by their name in the archive
116
+ for url, (name, data) in images.items():
117
+ if url not in used:
118
+ # An SVG melts as a child under its own name, so it hangs off the archive itself
119
+ at = src if Path(name).suffix.lower() == ".svg" else src.inside(name)
120
+ embeds.picture(at, data, name)
121
+ embeds.into(out)
122
+
123
+ out.needs += _frames(archive.get("WebSubframeArchives") or [], src, len(raw), out)
124
+ return out
125
+
126
+
127
+ def _frames(frames: list, src: Src, size: int, out: Converted) -> list[str]:
128
+ """Each frame is a whole webarchive of its own, melted again as a child
129
+
130
+ A binary plist can reference one frame object many times, and dumping each reference
131
+ again would multiply the archive at every nesting level. Identical frames go out once,
132
+ and the frames together never get more bytes than the archive itself holds
133
+ """
134
+ seen: set[int] = set()
135
+ dumped: set[bytes] = set()
136
+ left = FRAME_RATIO * size
137
+ many = large = 0
138
+ for i, frame in enumerate(frames, start=1):
139
+ if id(frame) in seen:
140
+ continue
141
+ seen.add(id(frame))
142
+ if len(dumped) >= MAX_FRAMES:
143
+ many += 1
144
+ continue
145
+ data = plistlib.dumps(frame, fmt=plistlib.FMT_BINARY)
146
+ digest = hashlib.sha256(data).digest()
147
+ if digest in dumped:
148
+ continue
149
+ if len(data) > left:
150
+ large += 1
151
+ continue
152
+ left -= len(data)
153
+ dumped.add(digest)
154
+ out.children.append(Child(f"frame{i}.webarchive", src, data=data))
155
+ needs = []
156
+ if many:
157
+ needs.append(not_read(many, "frame", f"over {MAX_FRAMES} frames"))
158
+ if large:
159
+ needs.append(not_read(large, "frame", "more frame data than the archive holds"))
160
+ return needs
@@ -74,6 +74,8 @@ max_bytes = 20000000
74
74
  # Binary files no converter reads get one more try: LibreOffice for the formats it imports,
75
75
  # Pillow, ffprobe, then on macOS Quick Look and Spotlight. Off keeps them as an unsupported need
76
76
  fallback = true
77
+ # Parquet rows melted per file, 0 for all. The rest stays counted as a need
78
+ parquet_rows = 200
77
79
 
78
80
  [render]
79
81
  # macOS only. Quick Look previews draw what LibreOffice can't or isn't installed for, like
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes