Spaces:
Running on Zero
Running on Zero
docparse upload UI
Browse files- .gitattributes +0 -34
- README.md +287 -7
- app.py +22 -0
- artifacts/docparse/manifest.json +14 -0
- artifacts/docparse/ocr.pt +3 -0
- artifacts/docparse/train.log +44 -0
- artifacts/docparse/train_info.json +77 -0
- docparse/__init__.py +10 -0
- docparse/bench.py +165 -0
- docparse/cli.py +78 -0
- docparse/data/corpus_test.txt +325 -0
- docparse/data/corpus_train.txt +0 -0
- docparse/extract.py +721 -0
- docparse/extractbench.py +120 -0
- docparse/ocr/__init__.py +0 -0
- docparse/ocr/charset.py +38 -0
- docparse/ocr/metrics.py +29 -0
- docparse/ocr/model.py +77 -0
- docparse/ocr/page.py +622 -0
- docparse/ocr/render.py +214 -0
- docparse/ocr/train.py +108 -0
- docparse/parsebench.py +128 -0
- docparse/synth_pages.py +148 -0
- docparse/ui.html +71 -0
- docparse/ui.py +56 -0
- packages.txt +3 -0
- requirements.txt +7 -0
.gitattributes
CHANGED
|
@@ -1,35 +1 @@
|
|
| 1 |
-
*.7z filter=lfs diff=lfs merge=lfs -text
|
| 2 |
-
*.arrow filter=lfs diff=lfs merge=lfs -text
|
| 3 |
-
*.bin filter=lfs diff=lfs merge=lfs -text
|
| 4 |
-
*.bz2 filter=lfs diff=lfs merge=lfs -text
|
| 5 |
-
*.ckpt filter=lfs diff=lfs merge=lfs -text
|
| 6 |
-
*.ftz filter=lfs diff=lfs merge=lfs -text
|
| 7 |
-
*.gz filter=lfs diff=lfs merge=lfs -text
|
| 8 |
-
*.h5 filter=lfs diff=lfs merge=lfs -text
|
| 9 |
-
*.joblib filter=lfs diff=lfs merge=lfs -text
|
| 10 |
-
*.lfs.* filter=lfs diff=lfs merge=lfs -text
|
| 11 |
-
*.mlmodel filter=lfs diff=lfs merge=lfs -text
|
| 12 |
-
*.model filter=lfs diff=lfs merge=lfs -text
|
| 13 |
-
*.msgpack filter=lfs diff=lfs merge=lfs -text
|
| 14 |
-
*.npy filter=lfs diff=lfs merge=lfs -text
|
| 15 |
-
*.npz filter=lfs diff=lfs merge=lfs -text
|
| 16 |
-
*.onnx filter=lfs diff=lfs merge=lfs -text
|
| 17 |
-
*.ot filter=lfs diff=lfs merge=lfs -text
|
| 18 |
-
*.parquet filter=lfs diff=lfs merge=lfs -text
|
| 19 |
-
*.pb filter=lfs diff=lfs merge=lfs -text
|
| 20 |
-
*.pickle filter=lfs diff=lfs merge=lfs -text
|
| 21 |
-
*.pkl filter=lfs diff=lfs merge=lfs -text
|
| 22 |
*.pt filter=lfs diff=lfs merge=lfs -text
|
| 23 |
-
*.pth filter=lfs diff=lfs merge=lfs -text
|
| 24 |
-
*.rar filter=lfs diff=lfs merge=lfs -text
|
| 25 |
-
*.safetensors filter=lfs diff=lfs merge=lfs -text
|
| 26 |
-
saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
| 27 |
-
*.tar.* filter=lfs diff=lfs merge=lfs -text
|
| 28 |
-
*.tar filter=lfs diff=lfs merge=lfs -text
|
| 29 |
-
*.tflite filter=lfs diff=lfs merge=lfs -text
|
| 30 |
-
*.tgz filter=lfs diff=lfs merge=lfs -text
|
| 31 |
-
*.wasm filter=lfs diff=lfs merge=lfs -text
|
| 32 |
-
*.xz filter=lfs diff=lfs merge=lfs -text
|
| 33 |
-
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
-
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
-
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
*.pt filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
README.md
CHANGED
|
@@ -1,13 +1,293 @@
|
|
| 1 |
---
|
| 2 |
-
title:
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: gradio
|
| 7 |
-
sdk_version:
|
| 8 |
-
python_version:
|
| 9 |
app_file: app.py
|
| 10 |
pinned: false
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
---
|
|
|
|
| 12 |
|
| 13 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
+
title: docparse
|
| 3 |
+
emoji: 📄
|
| 4 |
+
colorFrom: indigo
|
| 5 |
+
colorTo: gray
|
| 6 |
sdk: gradio
|
| 7 |
+
sdk_version: 5.49.1
|
| 8 |
+
python_version: "3.12"
|
| 9 |
app_file: app.py
|
| 10 |
pinned: false
|
| 11 |
+
license: mit
|
| 12 |
+
short_description: Extract text, tables and structure from PDF, image, Office
|
| 13 |
+
models:
|
| 14 |
+
- Kedhareswer/docparse
|
| 15 |
---
|
| 16 |
+
# docparse: a very small document parser
|
| 17 |
|
| 18 |
+
- **Try it:** [huggingface.co/spaces/Kedhareswer/docparse](https://huggingface.co/spaces/Kedhareswer/docparse) (drop a PDF, image, Word, Excel, CSV or PowerPoint file)
|
| 19 |
+
- **Weights and code:** [huggingface.co/Kedhareswer/docparse](https://huggingface.co/Kedhareswer/docparse): `artifacts/docparse/ocr.pt` (6.0 MB), the `docparse` package, benchmark reports and tests
|
| 20 |
+
- **Source:** [github.com/Kedhareswer/ML-AI](https://github.com/Kedhareswer/ML-AI) (`reasoning_harness/`)
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
`docparse` extracts **everything** from a document (text, headings, paragraphs,
|
| 24 |
+
lists, tables, sheets, formulas, headers/footers, metadata and figure positions)
|
| 25 |
+
and returns it as JSON, Markdown or plain text, with reading order preserved.
|
| 26 |
+
|
| 27 |
+
| Input | How it is read | Exact? |
|
| 28 |
+
|---|---|---|
|
| 29 |
+
| PDF with a text layer | Words and fonts from the PDF; layout analysis groups them into headings, paragraphs, lists, columns and tables | Text is exact |
|
| 30 |
+
| Scanned PDF pages (no text layer) | Rendered at 200 dpi, then the OCR pipeline below | OCR |
|
| 31 |
+
| Images: PNG, JPG, TIFF (multi-page), BMP, WEBP, GIF | OCR pipeline | OCR |
|
| 32 |
+
| Word `.docx` | Document structure: styles → headings/lists, tables (merged cells), text boxes, headers/footers, properties | Exact |
|
| 33 |
+
| Excel `.xlsx` | Every sheet: values, formulas, merged ranges, dates, separate tables split on empty rows/columns | Exact |
|
| 34 |
+
| CSV / TSV | Delimiter and encoding sniffed | Exact |
|
| 35 |
+
| `.doc`, `.rtf`, `.odt`, `.xls`, `.ods`, `.ppt`, `.pptx`, `.odp` | Converted with LibreOffice (if installed), then parsed as above | Exact |
|
| 36 |
+
|
| 37 |
+
The design choice: **a model is only used where one is needed.** Born-digital
|
| 38 |
+
files already contain their text; reading their structure is exact and much
|
| 39 |
+
faster than any network. The neural network reads pixels: images, scans and
|
| 40 |
+
(optionally, `--ocr-images`) pictures embedded inside documents.
|
| 41 |
+
|
| 42 |
+
## The model
|
| 43 |
+
|
| 44 |
+
A CRNN line recognizer trained **from scratch** (no pretrained weights) on a
|
| 45 |
+
4-core CPU:
|
| 46 |
+
|
| 47 |
+
- **1,484,357 parameters, 6.0 MB** (`artifacts/docparse/ocr.pt`): 6 convolution
|
| 48 |
+
layers, a 2-layer bidirectional LSTM and CTC decoding.
|
| 49 |
+
- Input: a grayscale text line, 32 px high, any width. Output: the text plus a
|
| 50 |
+
confidence (mean probability of the emitted characters).
|
| 51 |
+
- Characters: printable ASCII plus `• € £ ° ©`. Typographic quotes, dashes and
|
| 52 |
+
accented letters are folded to ASCII (`é` → `e`).
|
| 53 |
+
- Training: 480,000 synthetic lines (4 rounds of 120,000 freshly rendered
|
| 54 |
+
lines), 27 fonts (DejaVu, Liberation, FreeFont, Inter, JetBrains Mono),
|
| 55 |
+
text from app reviews and medical abstracts in this repository, plus
|
| 56 |
+
generated numbers, prices, dates, emails, URLs, IDs and list items. Lines are
|
| 57 |
+
degraded like scans and screenshots: blur, low resolution, noise, JPEG,
|
| 58 |
+
rotation, uneven light, inverted colours and table-rule remnants. Training
|
| 59 |
+
took 80 minutes.
|
| 60 |
+
|
| 61 |
+
Everything around the network is classical image processing (numpy only):
|
| 62 |
+
adaptive binarization (dark-on-light and light-on-dark), speck removal,
|
| 63 |
+
deskew, ruled-table detection from long horizontal/vertical strokes, frame
|
| 64 |
+
and outline removal, recursive XY-cut for columns and lines, then grouping
|
| 65 |
+
lines into headings (by x-height), paragraphs (by line spacing), list items,
|
| 66 |
+
and borderless tables (rows whose gaps line up).
|
| 67 |
+
|
| 68 |
+
## Results
|
| 69 |
+
|
| 70 |
+
All numbers are from `docparse bench` (`benchmarks/docparse/report.json`).
|
| 71 |
+
CER = character error rate (edit distance / reference length).
|
| 72 |
+
|
| 73 |
+
**1. Single lines.** Test fonts **Carlito and Caladea were never used in
|
| 74 |
+
training** (they are metric clones of Calibri and Cambria, the Office
|
| 75 |
+
defaults), and the test sentences come from a separate corpus.
|
| 76 |
+
|
| 77 |
+
| Test set | Lines | CER | Word error | Lines exactly right |
|
| 78 |
+
|---|---:|---:|---:|---:|
|
| 79 |
+
| Held-out fonts, clean | 2,000 | **0.27%** | 2.3% | 93.3% |
|
| 80 |
+
| Held-out fonts, degraded | 2,000 | **0.93%** | 6.0% | 84.8% |
|
| 81 |
+
| Training fonts, unseen text, degraded | 2,000 | 0.24% | 1.2% | 95.6% |
|
| 82 |
+
| Untrained network (same architecture), degraded | 500 | 99.7% | 99.8% | 0% |
|
| 83 |
+
|
| 84 |
+
About 3 ms per line on CPU.
|
| 85 |
+
|
| 86 |
+
**2. Synthetic A4 pages** (held-out fonts and text; titles, section headings,
|
| 87 |
+
paragraphs, bullet lists, ruled tables, key/value tables, two-column sections).
|
| 88 |
+
"Scanned" pages are rotated up to ±2°, blurred, noised and JPEG-compressed.
|
| 89 |
+
|
| 90 |
+
| | Clean (40 pages) | Scanned (40 pages) |
|
| 91 |
+
|---|---:|---:|
|
| 92 |
+
| Page CER, in reading order (mean / median) | 0.32% / 0.00% | 1.65% / 0.19% |
|
| 93 |
+
| Word F1 (order-free) | 98.9% | 97.7% |
|
| 94 |
+
| Headings found | 86.7% | 85.7% |
|
| 95 |
+
| Tables found with the right rows × columns | 100% | 100% |
|
| 96 |
+
| Table cells exactly right | 98.5% | 90.2% |
|
| 97 |
+
| Seconds per page (CPU) | 0.9 | 1.3 |
|
| 98 |
+
|
| 99 |
+
A few hard pages pull the scanned mean up; the median page is almost perfect.
|
| 100 |
+
Missed headings are mostly short numbered section titles ("1. Results") read
|
| 101 |
+
as list items or paragraphs: headings are detected by letter size, not by
|
| 102 |
+
boldness.
|
| 103 |
+
|
| 104 |
+
**3. Real pages from this repository.** 40 pages of the three PDFs in this
|
| 105 |
+
repo (two Jupyter notebook exports and a slide deck) were rendered to
|
| 106 |
+
150 dpi images and read **from pixels only**. Each PDF's own text layer is the
|
| 107 |
+
reference.
|
| 108 |
+
|
| 109 |
+
| PDF | Pages | Median CER (reading order) | Median word F1 |
|
| 110 |
+
|---|---:|---:|---:|
|
| 111 |
+
| Customer Behavior Analysis (notebook) | 15 | 0.7% | 92.5% |
|
| 112 |
+
| Credit Card Fraud Detection (notebook) | 11 | 2.3% | 94.8% |
|
| 113 |
+
| PPT.pdf (slides with graphics) | 14 | 32.3% | 82.5% |
|
| 114 |
+
| **All** | **40** | — | **91.9%** (mean 86.3%) |
|
| 115 |
+
|
| 116 |
+
Most slide-deck error is reading order: slides scatter text boxes, and the
|
| 117 |
+
PDF's own order differs from top-to-bottom reading. Text inside charts (axis
|
| 118 |
+
labels, tick numbers) is also read as text. The weakest notebook pages are
|
| 119 |
+
mostly plots with a few short labels.
|
| 120 |
+
|
| 121 |
+
## ParseBench (LlamaIndex)
|
| 122 |
+
|
| 123 |
+
[ParseBench](https://huggingface.co/datasets/llamaindex/ParseBench)
|
| 124 |
+
([paper](https://arxiv.org/abs/2604.08538), [code](https://github.com/run-llama/ParseBench))
|
| 125 |
+
is LlamaIndex's public benchmark for document parsers: **2,078 real enterprise
|
| 126 |
+
pages** (insurance filings, financial reports, government documents, papers),
|
| 127 |
+
1,211 documents and 169,011 human-verified test rules in five dimensions.
|
| 128 |
+
Scoring is deterministic rule matching, with no LLM judge.
|
| 129 |
+
|
| 130 |
+
docparse was run on the **full** dataset with the official harness
|
| 131 |
+
(parse-bench 1.0.4) on a 4-core CPU: all 2,078 pages, 0 failures, median
|
| 132 |
+
0.5 s per page. The comparison rows are the "Open Source - Local" entries of
|
| 133 |
+
ParseBench's published leaderboard.
|
| 134 |
+
|
| 135 |
+
| Parser | Overall | Tables | Charts | Content faithfulness | Semantic formatting | Visual grounding |
|
| 136 |
+
|---|---:|---:|---:|---:|---:|---:|
|
| 137 |
+
| PyMuPDF4LLM | 53.5 | 72.0 | 2.2 | 79.3 | 52.0 | 61.9 |
|
| 138 |
+
| Warp Ingest | 40.2 | 57.5 | 7.0 | 70.8 | 45.8 | 19.8 |
|
| 139 |
+
| LiteParse (no OCR) | 36.9 | 42.4 | 1.8 | 70.0 | 38.5 | 31.8 |
|
| 140 |
+
| **docparse** | **36.2** | **42.1** | **1.0** | **72.5** | **35.5** | **30.0** |
|
| 141 |
+
| OpenDataLoader | 29.4 | 35.2 | 0.9 | 66.1 | 34.1 | 10.8 |
|
| 142 |
+
| pdf-inspector | 26.6 | 26.6 | 5.3 | 56.1 | 35.1 | 9.9 |
|
| 143 |
+
| MarkItDown | 18.6 | 15.8 | 2.0 | 64.5 | 0.9 | 9.9 |
|
| 144 |
+
| PyMuPDF (text) | 16.0 | 0.0 | 0.0 | 68.3 | 1.0 | 10.9 |
|
| 145 |
+
| pypdf | 14.9 | 0.0 | 0.0 | 62.5 | 0.9 | 10.9 |
|
| 146 |
+
|
| 147 |
+
Overall is the mean of the five dimensions, as on the official leaderboard.
|
| 148 |
+
For scale: the top of the full leaderboard (cloud services and multi-billion
|
| 149 |
+
parameter vision-language models) reaches 75–90.
|
| 150 |
+
|
| 151 |
+
**What this says honestly**
|
| 152 |
+
|
| 153 |
+
- docparse is mid-table among local open-source parsers: ahead of pypdf,
|
| 154 |
+
PyMuPDF, MarkItDown, pdf-inspector and OpenDataLoader; just behind LiteParse
|
| 155 |
+
(36.9); behind Warp Ingest and clearly behind PyMuPDF4LLM.
|
| 156 |
+
- Its **content faithfulness (72.5)** is second among local parsers. It is
|
| 157 |
+
strongest on simple (89.0) and multi-column (76.5) pages, and weakest on
|
| 158 |
+
handwriting (27.4), scans (56.1) and non-English text (58.8). That matches the
|
| 159 |
+
OCR model's limits: printed Latin text only.
|
| 160 |
+
- **Charts (1.0):** ParseBench wants each chart's data points as a table.
|
| 161 |
+
docparse finds charts and keeps their labels but does not convert them to
|
| 162 |
+
data. No local parser scores above 7.
|
| 163 |
+
- **Tables (42.1)** are limited by pdfplumber's ruled-grid detection on
|
| 164 |
+
borderless financial tables; 32.3 on "hard" tables.
|
| 165 |
+
- **Formatting (35.5):** bold 29%, italic 23%, superscript 59%, subscript 57%
|
| 166 |
+
of rules pass. Underline, strikeout, highlight, LaTeX and code blocks are not
|
| 167 |
+
produced (0%).
|
| 168 |
+
- **Visual grounding (30.0):** paragraph boxes and reading order are decent;
|
| 169 |
+
figure regions and box granularity often differ from the annotation.
|
| 170 |
+
|
| 171 |
+
**What the benchmark changed in docparse.** The first full run scored 33.66; the final run scored 36.2.
|
| 172 |
+
Its failures led to fixes that are now part of docparse itself:
|
| 173 |
+
|
| 174 |
+
| Change | Effect |
|
| 175 |
+
|---|---|
|
| 176 |
+
| Bold/italic from PDF font names, `<sup>`/`<sub>` from glyph size and baseline | Semantic formatting 24.0 → 35.5 |
|
| 177 |
+
| Running header/footer and printed page-number detection | Header rules 0 → 9.9%, footer rules 0 → 12.2%; content faithfulness 71.8 → 72.5 |
|
| 178 |
+
| Rotated margin text (e.g. arXiv stamps) kept out of the reading flow | It no longer turns body text into a fake table |
|
| 179 |
+
| Vector charts and diagrams detected as figures, their labels attached | Visual grounding 28.9 → 30.0 |
|
| 180 |
+
| Rows stacked in one cell of rule-less tables split into records | Correct rows in insurance rate tables |
|
| 181 |
+
| A malformed PDF link annotation no longer fails the whole page | 9 failing pages → 0 |
|
| 182 |
+
|
| 183 |
+
Reproduce (parse-bench needs Python 3.12+):
|
| 184 |
+
|
| 185 |
+
```bash
|
| 186 |
+
pip install "parse-bench[fast]"
|
| 187 |
+
python scripts/parsebench_docparse.py download # ~600 MB
|
| 188 |
+
python scripts/parsebench_docparse.py run docparse --max_concurrent 3
|
| 189 |
+
python scripts/parsebench_summary.py output/docparse leaderboard.csv
|
| 190 |
+
```
|
| 191 |
+
|
| 192 |
+
`docparse/parsebench.py` registers the pipeline through ParseBench's public
|
| 193 |
+
extension API. Each page is sent as Markdown with HTML tables, page
|
| 194 |
+
header/footer fields, and layout boxes in ParseBench's canonical classes.
|
| 195 |
+
Aggregate results are in `benchmarks/parsebench/report.json`.
|
| 196 |
+
|
| 197 |
+
## ExtractBench (LlamaIndex): small local check
|
| 198 |
+
|
| 199 |
+
[ExtractBench](https://github.com/run-llama/ExtractBench) scores **schema-guided
|
| 200 |
+
extraction**: a document plus a JSON Schema in, schema-valid JSON plus evidence
|
| 201 |
+
boxes out. A parser cannot do this alone; an LLM fills the schema. So docparse
|
| 202 |
+
was tested as the **parse stage** of a two-stage pipeline (as ExtractBench's own
|
| 203 |
+
parse → LLM baselines are), against plain pypdf text, with everything else fixed:
|
| 204 |
+
the same model (Qwen2.5-3B-Instruct, 4-bit, llama.cpp on 4 CPU cores), the same
|
| 205 |
+
prompt and 12,288-token context budget, and the official scorer.
|
| 206 |
+
|
| 207 |
+
| Parse stage (6-document test set) | Value F1 | Precision | Recall |
|
| 208 |
+
|---|---:|---:|---:|
|
| 209 |
+
| **docparse** | **35.8** | 63.8 | 32.5 |
|
| 210 |
+
| pypdf | 32.9 | 66.3 | 29.8 |
|
| 211 |
+
|
| 212 |
+
docparse helped most on a scanned document (Value F1 19.6 vs 8.0, from OCR) and
|
| 213 |
+
an earnings deck with tables (35.0 vs 25.0). It lost on the 66-page document
|
| 214 |
+
(1.4 vs 5.2), where both arms could send only the first few pages.
|
| 215 |
+
|
| 216 |
+
**Read this carefully.** This is 6 documents, so a 2.9-point gap is not
|
| 217 |
+
statistically meaningful. Absolute scores are far below the leaderboard (top
|
| 218 |
+
systems score ~96), because a 3B model with a 12k context must cut long documents
|
| 219 |
+
(up to 55 of 66 pages dropped) and sometimes even shorten the schema. Grounding is
|
| 220 |
+
0 for both arms because this pipeline does not return evidence boxes yet. A real
|
| 221 |
+
ExtractBench number needs a strong model over all 370 documents (ExtractBench
|
| 222 |
+
estimates $10 to $1,677 per full run, depending on the model). Per-document
|
| 223 |
+
details are in `benchmarks/extractbench/report.json`.
|
| 224 |
+
|
| 225 |
+
Reproduce: start an OpenAI-compatible server (`python -m llama_cpp.server --model
|
| 226 |
+
qwen2.5-3b-instruct-q4_k_m.gguf --n_ctx 12288 --port 8088`), then
|
| 227 |
+
`DOCPARSE_LLM_URL=http://127.0.0.1:8088 python scripts/extractbench_docparse.py run
|
| 228 |
+
docparse_extract_local --test` (and `pypdf_extract_local`).
|
| 229 |
+
|
| 230 |
+
**Structure parsers** (PDF text layer, DOCX, XLSX, CSV) are tested for exact
|
| 231 |
+
output in `tests/test_docparse.py`.
|
| 232 |
+
|
| 233 |
+
## Use it
|
| 234 |
+
|
| 235 |
+
```bash
|
| 236 |
+
pip install -e '.[docs]' # torch, numpy, pillow, pdfplumber, pypdfium2, python-docx, openpyxl
|
| 237 |
+
|
| 238 |
+
docparse extract report.pdf # Markdown to stdout
|
| 239 |
+
docparse extract scan.png --format json -o scan.json
|
| 240 |
+
docparse extract invoice.pdf --ocr always # OCR every page, even with a text layer
|
| 241 |
+
docparse extract deck.docx --ocr-images # also read text inside embedded pictures
|
| 242 |
+
docparse extract *.xlsx --format md -o out/ # many files -> a directory
|
| 243 |
+
docparse ui # browser UI on http://127.0.0.1:7862
|
| 244 |
+
```
|
| 245 |
+
|
| 246 |
+
```python
|
| 247 |
+
from docparse import extract
|
| 248 |
+
from docparse.extract import to_markdown
|
| 249 |
+
|
| 250 |
+
doc = extract("statement.pdf") # dict: source, format, metadata, pages, blocks, sheets, warnings
|
| 251 |
+
for b in doc["blocks"]:
|
| 252 |
+
print(b["type"], b.get("page"), b.get("text") or b.get("rows"))
|
| 253 |
+
print(to_markdown(doc))
|
| 254 |
+
```
|
| 255 |
+
|
| 256 |
+
Each block has a `type` (`heading`, `paragraph`, `list_item`, `table`,
|
| 257 |
+
`figure`, `header`, `footer`), a `source` (`text-layer`, `structure` or
|
| 258 |
+
`ocr`), and where meaningful a `page`, `bbox`, `level`, `sheet`, `range` and
|
| 259 |
+
an OCR `confidence`. Excel sheets also list formulas, merged ranges, charts and
|
| 260 |
+
images under `sheets`.
|
| 261 |
+
|
| 262 |
+
## Train or fine-tune your own
|
| 263 |
+
|
| 264 |
+
```bash
|
| 265 |
+
python scripts/build_doc_corpus.py # rebuild the text corpora
|
| 266 |
+
docparse train-ocr --run runs/ocr --rounds 4 # ~80 min on 4 CPU cores
|
| 267 |
+
docparse train-ocr --run runs/ocr --rounds 6 --resume # continue / extend training
|
| 268 |
+
docparse bench --model runs/ocr/ocr.pt --output runs/ocr-bench
|
| 269 |
+
```
|
| 270 |
+
|
| 271 |
+
To specialise it (your fonts, your vocabulary, e.g. invoices in one
|
| 272 |
+
typeface), add font files to `TRAIN_FONTS` and text to `docparse/data/corpus_train.txt`
|
| 273 |
+
in `docparse/ocr/render.py`, then train or resume. A checkpoint is written
|
| 274 |
+
after every round.
|
| 275 |
+
|
| 276 |
+
## Limits
|
| 277 |
+
|
| 278 |
+
- **Printed text only.** No handwriting. Latin script only (ASCII plus a few
|
| 279 |
+
symbols); accents are dropped and other scripts are not read.
|
| 280 |
+
- Tables: ruled grids and borderless tables with aligned columns are found.
|
| 281 |
+
Cells that span several rows or columns are kept in the first cell. Very
|
| 282 |
+
irregular tables may come out as text.
|
| 283 |
+
- Reading order is top-to-bottom, columns left to right. Magazine layouts,
|
| 284 |
+
slides with scattered boxes, sidebars and text running around pictures can
|
| 285 |
+
be read out of order.
|
| 286 |
+
- Pictures are reported as `figure` blocks with a position. Charts are not
|
| 287 |
+
converted to data.
|
| 288 |
+
- Photos of documents taken at an angle (perspective), curved pages and
|
| 289 |
+
very low resolution (text under about 10 px high) are not handled well.
|
| 290 |
+
- Born-digital PDFs are only as good as their text layer: some PDFs embed
|
| 291 |
+
fonts without a Unicode mapping. Use `--ocr always` for those.
|
| 292 |
+
- Besides ParseBench, the benchmarks are synthetic pages and three real PDFs from
|
| 293 |
+
this repository. No direct comparison with Tesseract was run here.
|
app.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Entry point for the free Gradio-SDK Space: Spaces run `python app.py` and route
|
| 2 |
+
# traffic to port 7860, so this starts docparse's own upload UI there.
|
| 3 |
+
import os
|
| 4 |
+
|
| 5 |
+
os.environ.setdefault("OMP_NUM_THREADS", "2")
|
| 6 |
+
|
| 7 |
+
try: # ZeroGPU (the only free Gradio hardware) refuses to start without one
|
| 8 |
+
import spaces
|
| 9 |
+
|
| 10 |
+
@spaces.GPU
|
| 11 |
+
def _unused():
|
| 12 |
+
pass
|
| 13 |
+
|
| 14 |
+
# spaces normally reports this from gr.Blocks.launch, which this app never calls
|
| 15 |
+
from spaces.zero import client
|
| 16 |
+
client.startup_report()
|
| 17 |
+
except ImportError:
|
| 18 |
+
pass
|
| 19 |
+
|
| 20 |
+
from docparse.ui import serve
|
| 21 |
+
|
| 22 |
+
serve(port=7860, host="0.0.0.0", model="artifacts/docparse/ocr.pt")
|
artifacts/docparse/manifest.json
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"model": "LineOCR (CRNN: 6 conv layers + 2-layer BiLSTM, CTC)",
|
| 3 |
+
"parameters": 1484357,
|
| 4 |
+
"weights_bytes": 5958917,
|
| 5 |
+
"sha256": "ed073bd91ee8d12727b335ee08154e98c2fcc35c54e3083b7c25dd49cff1da8d",
|
| 6 |
+
"input": "grayscale text line, height 32, any width",
|
| 7 |
+
"charset": "printable ASCII + \u2022 \u20ac \u00a3 \u00b0 \u00a9",
|
| 8 |
+
"training_data": "480,000 synthetic lines (4 rounds x 120,000, fresh seeds each round), 27 training fonts",
|
| 9 |
+
"held_out": "Carlito and Caladea font families (metric clones of Calibri/Cambria) and a separate text corpus",
|
| 10 |
+
"train_seconds": 4829.3,
|
| 11 |
+
"device": "cpu (4 cores)",
|
| 12 |
+
"torch": "2.6.0+cpu",
|
| 13 |
+
"initialisation": "random (torch.manual_seed 0); no pretrained weights"
|
| 14 |
+
}
|
artifacts/docparse/ocr.pt
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:ed073bd91ee8d12727b335ee08154e98c2fcc35c54e3083b7c25dd49cff1da8d
|
| 3 |
+
size 5958917
|
artifacts/docparse/train.log
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
round 1 step 0/2500 loss 12.8047 lr 8.00e-05 elapsed 100s
|
| 2 |
+
round 1 step 250/2500 loss 5.2012 lr 5.11e-04 elapsed 219s
|
| 3 |
+
round 1 step 500/2500 loss 3.2770 lr 1.41e-03 elapsed 357s
|
| 4 |
+
round 1 step 750/2500 loss 0.8169 lr 1.98e-03 elapsed 536s
|
| 5 |
+
round 1 step 1000/2500 loss 0.2078 lr 2.00e-03 elapsed 657s
|
| 6 |
+
round 1 step 1250/2500 loss 0.0972 lr 1.99e-03 elapsed 782s
|
| 7 |
+
round 1 step 1500/2500 loss 0.0859 lr 1.97e-03 elapsed 892s
|
| 8 |
+
round 1 step 1750/2500 loss 0.0785 lr 1.95e-03 elapsed 1010s
|
| 9 |
+
round 1 step 2000/2500 loss 0.0683 lr 1.92e-03 elapsed 1112s
|
| 10 |
+
round 1 step 2250/2500 loss 0.0497 lr 1.88e-03 elapsed 1211s
|
| 11 |
+
{"round": 1, "train_loss": 0.0498, "val_train_fonts": {"lines": 3000, "cer": 0.01403, "wer": 0.06834, "line_exact": 0.8077}, "test_heldout_fonts": {"lines": 3000, "cer": 0.02851, "wer": 0.13867, "line_exact": 0.6483}, "elapsed_s": 1325.6}
|
| 12 |
+
round 2 step 0/2500 loss 0.0188 lr 1.84e-03 elapsed 1463s
|
| 13 |
+
round 2 step 250/2500 loss 0.0467 lr 1.79e-03 elapsed 1687s
|
| 14 |
+
round 2 step 500/2500 loss 0.0386 lr 1.73e-03 elapsed 1922s
|
| 15 |
+
round 2 step 750/2500 loss 0.0529 lr 1.67e-03 elapsed 2126s
|
| 16 |
+
round 2 step 1000/2500 loss 0.0415 lr 1.60e-03 elapsed 2214s
|
| 17 |
+
round 2 step 1250/2500 loss 0.0349 lr 1.53e-03 elapsed 2311s
|
| 18 |
+
round 2 step 1500/2500 loss 0.0321 lr 1.46e-03 elapsed 2396s
|
| 19 |
+
round 2 step 1750/2500 loss 0.0271 lr 1.38e-03 elapsed 2494s
|
| 20 |
+
round 2 step 2000/2500 loss 0.0233 lr 1.30e-03 elapsed 2587s
|
| 21 |
+
round 2 step 2250/2500 loss 0.0313 lr 1.22e-03 elapsed 2679s
|
| 22 |
+
{"round": 2, "train_loss": 0.028, "val_train_fonts": {"lines": 3000, "cer": 0.0051, "wer": 0.02588, "line_exact": 0.916}, "test_heldout_fonts": {"lines": 3000, "cer": 0.01489, "wer": 0.08693, "line_exact": 0.779}, "elapsed_s": 2781.7}
|
| 23 |
+
round 3 step 0/2500 loss 0.0044 lr 1.14e-03 elapsed 2882s
|
| 24 |
+
round 3 step 250/2500 loss 0.0210 lr 1.05e-03 elapsed 2970s
|
| 25 |
+
round 3 step 500/2500 loss 0.0229 lr 9.65e-04 elapsed 3064s
|
| 26 |
+
round 3 step 750/2500 loss 0.0220 lr 8.80e-04 elapsed 3154s
|
| 27 |
+
round 3 step 1000/2500 loss 0.0232 lr 7.96e-04 elapsed 3246s
|
| 28 |
+
round 3 step 1250/2500 loss 0.0204 lr 7.13e-04 elapsed 3338s
|
| 29 |
+
round 3 step 1500/2500 loss 0.0172 lr 6.33e-04 elapsed 3433s
|
| 30 |
+
round 3 step 1750/2500 loss 0.0147 lr 5.55e-04 elapsed 3523s
|
| 31 |
+
round 3 step 2000/2500 loss 0.0155 lr 4.80e-04 elapsed 3613s
|
| 32 |
+
round 3 step 2250/2500 loss 0.0136 lr 4.09e-04 elapsed 3695s
|
| 33 |
+
{"round": 3, "train_loss": 0.0135, "val_train_fonts": {"lines": 3000, "cer": 0.00235, "wer": 0.01228, "line_exact": 0.958}, "test_heldout_fonts": {"lines": 3000, "cer": 0.00977, "wer": 0.06076, "line_exact": 0.834}, "elapsed_s": 3805.6}
|
| 34 |
+
round 4 step 0/2500 loss 0.0053 lr 3.42e-04 elapsed 3904s
|
| 35 |
+
round 4 step 250/2500 loss 0.0118 lr 2.80e-04 elapsed 3994s
|
| 36 |
+
round 4 step 500/2500 loss 0.0119 lr 2.24e-04 elapsed 4084s
|
| 37 |
+
round 4 step 750/2500 loss 0.0112 lr 1.73e-04 elapsed 4172s
|
| 38 |
+
round 4 step 1000/2500 loss 0.0106 lr 1.28e-04 elapsed 4261s
|
| 39 |
+
round 4 step 1250/2500 loss 0.0099 lr 8.94e-05 elapsed 4353s
|
| 40 |
+
round 4 step 1500/2500 loss 0.0120 lr 5.75e-05 elapsed 4436s
|
| 41 |
+
round 4 step 1750/2500 loss 0.0094 lr 3.25e-05 elapsed 4529s
|
| 42 |
+
round 4 step 2000/2500 loss 0.0099 lr 1.44e-05 elapsed 4627s
|
| 43 |
+
round 4 step 2250/2500 loss 0.0097 lr 3.59e-06 elapsed 4717s
|
| 44 |
+
{"round": 4, "train_loss": 0.009, "val_train_fonts": {"lines": 3000, "cer": 0.00168, "wer": 0.00907, "line_exact": 0.9683}, "test_heldout_fonts": {"lines": 3000, "cer": 0.00848, "wer": 0.05398, "line_exact": 0.8533}, "elapsed_s": 4829.3}
|
artifacts/docparse/train_info.json
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"parameters": 1484357,
|
| 3 |
+
"rounds": 4,
|
| 4 |
+
"lines_per_round": 120000,
|
| 5 |
+
"batch_size": 48,
|
| 6 |
+
"seed": 0,
|
| 7 |
+
"history": [
|
| 8 |
+
{
|
| 9 |
+
"round": 1,
|
| 10 |
+
"train_loss": 0.0498,
|
| 11 |
+
"val_train_fonts": {
|
| 12 |
+
"lines": 3000,
|
| 13 |
+
"cer": 0.01403,
|
| 14 |
+
"wer": 0.06834,
|
| 15 |
+
"line_exact": 0.8077
|
| 16 |
+
},
|
| 17 |
+
"test_heldout_fonts": {
|
| 18 |
+
"lines": 3000,
|
| 19 |
+
"cer": 0.02851,
|
| 20 |
+
"wer": 0.13867,
|
| 21 |
+
"line_exact": 0.6483
|
| 22 |
+
},
|
| 23 |
+
"elapsed_s": 1325.6
|
| 24 |
+
},
|
| 25 |
+
{
|
| 26 |
+
"round": 2,
|
| 27 |
+
"train_loss": 0.028,
|
| 28 |
+
"val_train_fonts": {
|
| 29 |
+
"lines": 3000,
|
| 30 |
+
"cer": 0.0051,
|
| 31 |
+
"wer": 0.02588,
|
| 32 |
+
"line_exact": 0.916
|
| 33 |
+
},
|
| 34 |
+
"test_heldout_fonts": {
|
| 35 |
+
"lines": 3000,
|
| 36 |
+
"cer": 0.01489,
|
| 37 |
+
"wer": 0.08693,
|
| 38 |
+
"line_exact": 0.779
|
| 39 |
+
},
|
| 40 |
+
"elapsed_s": 2781.7
|
| 41 |
+
},
|
| 42 |
+
{
|
| 43 |
+
"round": 3,
|
| 44 |
+
"train_loss": 0.0135,
|
| 45 |
+
"val_train_fonts": {
|
| 46 |
+
"lines": 3000,
|
| 47 |
+
"cer": 0.00235,
|
| 48 |
+
"wer": 0.01228,
|
| 49 |
+
"line_exact": 0.958
|
| 50 |
+
},
|
| 51 |
+
"test_heldout_fonts": {
|
| 52 |
+
"lines": 3000,
|
| 53 |
+
"cer": 0.00977,
|
| 54 |
+
"wer": 0.06076,
|
| 55 |
+
"line_exact": 0.834
|
| 56 |
+
},
|
| 57 |
+
"elapsed_s": 3805.6
|
| 58 |
+
},
|
| 59 |
+
{
|
| 60 |
+
"round": 4,
|
| 61 |
+
"train_loss": 0.009,
|
| 62 |
+
"val_train_fonts": {
|
| 63 |
+
"lines": 3000,
|
| 64 |
+
"cer": 0.00168,
|
| 65 |
+
"wer": 0.00907,
|
| 66 |
+
"line_exact": 0.9683
|
| 67 |
+
},
|
| 68 |
+
"test_heldout_fonts": {
|
| 69 |
+
"lines": 3000,
|
| 70 |
+
"cer": 0.00848,
|
| 71 |
+
"wer": 0.05398,
|
| 72 |
+
"line_exact": 0.8533
|
| 73 |
+
},
|
| 74 |
+
"elapsed_s": 4829.3
|
| 75 |
+
}
|
| 76 |
+
]
|
| 77 |
+
}
|
docparse/__init__.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""docparse: extract text, tables and structure from PDF, images, Word and Excel files.
|
| 2 |
+
|
| 3 |
+
Born-digital formats are parsed exactly from their own structure. Images and scanned
|
| 4 |
+
PDF pages go through a small OCR network trained from scratch (docparse.ocr).
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
def extract(path, **kw):
|
| 9 |
+
from .extract import extract as _extract
|
| 10 |
+
return _extract(path, **kw)
|
docparse/bench.py
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Benchmarks: line OCR, synthetic pages, and real PDF pages read from pixels.
|
| 2 |
+
|
| 3 |
+
1. Lines: held-out fonts (Carlito/Caladea) + held-out text, clean and degraded; the
|
| 4 |
+
training fonts on unseen text are reported separately, as is an untrained network.
|
| 5 |
+
2. Pages: synthetic A4 pages (held-out fonts/text), clean renders and simulated scans.
|
| 6 |
+
Scores: page CER in reading order, order-free word F1, heading recall, table recovery.
|
| 7 |
+
3. Real pages: born-digital PDFs from this repository rendered to images and OCR'd; the
|
| 8 |
+
PDF's own text layer is the reference. Reading order of complex pages differs, so
|
| 9 |
+
the order-free word F1 is the fairer score there.
|
| 10 |
+
"""
|
| 11 |
+
import json
|
| 12 |
+
import random
|
| 13 |
+
import time
|
| 14 |
+
from collections import Counter
|
| 15 |
+
from pathlib import Path
|
| 16 |
+
|
| 17 |
+
import numpy as np
|
| 18 |
+
import torch
|
| 19 |
+
|
| 20 |
+
from .ocr.metrics import cer, summary
|
| 21 |
+
from .ocr.model import LineOCR, Recognizer
|
| 22 |
+
from .ocr.render import TRAIN_FONTS, available, dataset, render_line, sample_text
|
| 23 |
+
from .synth_pages import make_page, truth_text
|
| 24 |
+
|
| 25 |
+
ROOT = Path(__file__).resolve().parents[1]
|
| 26 |
+
REAL_PDFS = ["../Credit Card/Credit Card Fraud Detection.pdf", "../ML/PPT.pdf",
|
| 27 |
+
"../Customer Analysis/vertopal.com_Customer_Behavior_Analysis.pdf"]
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def _norm(t):
|
| 31 |
+
return " ".join(str(t).split())
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def word_f1(pred, ref):
|
| 35 |
+
p, r = Counter(pred.split()), Counter(ref.split())
|
| 36 |
+
hit = sum((p & r).values())
|
| 37 |
+
if not hit:
|
| 38 |
+
return 0.0
|
| 39 |
+
prec, rec = hit / sum(p.values()), hit / sum(r.values())
|
| 40 |
+
return 2 * prec * rec / (prec + rec)
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def lines(rec, count=2000):
|
| 44 |
+
out = {}
|
| 45 |
+
for name, split, degrade, seed in [("heldout_fonts_clean", "test", False, 7_000_001),
|
| 46 |
+
("heldout_fonts_degraded", "test", True, 7_000_002),
|
| 47 |
+
("train_fonts_unseen_text_degraded", "train", True, 7_000_003)]:
|
| 48 |
+
if split == "train": # training fonts with held-out text, so no sentence was seen in training
|
| 49 |
+
ims, txt = [], []
|
| 50 |
+
fonts = available(TRAIN_FONTS)
|
| 51 |
+
for i in range(count):
|
| 52 |
+
r = random.Random(seed * 1_000_003 + i)
|
| 53 |
+
txt.append(sample_text(r, "test"))
|
| 54 |
+
ims.append(render_line(txt[-1], r.choice(fonts), r.randint(14, 44), r, degrade))
|
| 55 |
+
else:
|
| 56 |
+
ims, txt = dataset(count, seed, split, degrade)
|
| 57 |
+
t0 = time.time()
|
| 58 |
+
preds = [p for p, _ in rec.read(ims, batch=64)]
|
| 59 |
+
res = summary(preds, txt)
|
| 60 |
+
res["ms_per_line"] = round(1000 * (time.time() - t0) / count, 2)
|
| 61 |
+
res["examples"] = [{"truth": t, "pred": p} for t, p in list(zip(txt, preds))[:8]]
|
| 62 |
+
out[name] = res
|
| 63 |
+
# Untrained control: same architecture, random weights.
|
| 64 |
+
torch.manual_seed(0)
|
| 65 |
+
blank = Recognizer.__new__(Recognizer)
|
| 66 |
+
blank.net, blank.device = LineOCR().eval(), "cpu"
|
| 67 |
+
ims, txt = dataset(500, 7_000_002, "test", True)
|
| 68 |
+
out["untrained_network_heldout_degraded"] = summary([p for p, _ in blank.read(ims)], txt)
|
| 69 |
+
return out
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def _flat(blocks):
|
| 73 |
+
out = []
|
| 74 |
+
for b in blocks:
|
| 75 |
+
if b["type"] == "table":
|
| 76 |
+
out += [" ".join(str(c) for c in row if str(c)) for row in b["rows"]]
|
| 77 |
+
elif b.get("text"):
|
| 78 |
+
out.append(b["text"])
|
| 79 |
+
return _norm(" ".join(out))
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def pages(rec, count=40):
|
| 83 |
+
from .ocr.page import analyze
|
| 84 |
+
res = {}
|
| 85 |
+
for scan in (False, True):
|
| 86 |
+
cers, f1s, heads, head_hits, tabs, tab_hits, cells, cell_hits, secs = [], [], 0, 0, 0, 0, 0, 0, []
|
| 87 |
+
for i in range(count):
|
| 88 |
+
img, truth = make_page(8_000_000 + i + (100_000 if scan else 0), "test", scan)
|
| 89 |
+
t0 = time.time()
|
| 90 |
+
blocks, _ = analyze(img, rec)
|
| 91 |
+
secs.append(time.time() - t0)
|
| 92 |
+
ref, pred = _norm(truth_text(truth)), _flat(blocks)
|
| 93 |
+
cers.append(cer(pred, ref))
|
| 94 |
+
f1s.append(word_f1(pred, ref))
|
| 95 |
+
pheads = [b["text"] for b in blocks if b["type"] == "heading"]
|
| 96 |
+
for b in truth:
|
| 97 |
+
if b["type"] == "heading":
|
| 98 |
+
heads += 1
|
| 99 |
+
head_hits += any(cer(p, b["text"]) < 0.2 for p in pheads)
|
| 100 |
+
if b["type"] == "table":
|
| 101 |
+
tabs += 1
|
| 102 |
+
shape = (len(b["rows"]), len(b["rows"][0]))
|
| 103 |
+
match = [p for p in blocks if p["type"] == "table" and (len(p["rows"]), len(p["rows"][0])) == shape]
|
| 104 |
+
if match:
|
| 105 |
+
tab_hits += 1
|
| 106 |
+
best = max(match, key=lambda p: sum(_norm(x) == _norm(y) for rp, rt in zip(p["rows"], b["rows"])
|
| 107 |
+
for x, y in zip(rp, rt)))
|
| 108 |
+
cell_hits += sum(_norm(x) == _norm(y) for rp, rt in zip(best["rows"], b["rows"]) for x, y in zip(rp, rt))
|
| 109 |
+
cells += shape[0] * shape[1]
|
| 110 |
+
res["scanned" if scan else "clean"] = {
|
| 111 |
+
"pages": count, "page_cer_reading_order": round(float(np.mean(cers)), 4),
|
| 112 |
+
"page_cer_median": round(float(np.median(cers)), 4), "word_f1": round(float(np.mean(f1s)), 4),
|
| 113 |
+
"heading_recall": round(head_hits / max(1, heads), 4), "headings": heads,
|
| 114 |
+
"tables_found_with_correct_shape": round(tab_hits / max(1, tabs), 4), "tables": tabs,
|
| 115 |
+
"table_cells_exact": round(cell_hits / max(1, cells), 4), "cells": cells,
|
| 116 |
+
"seconds_per_page": round(float(np.mean(secs)), 2)}
|
| 117 |
+
return res
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
def real_pdfs(rec, dpi=150, max_pages=40):
|
| 121 |
+
"""Render born-digital pages to pixels, OCR them, and compare with the PDF's text layer."""
|
| 122 |
+
import pdfplumber
|
| 123 |
+
import pypdfium2 as pdfium
|
| 124 |
+
from .ocr.page import analyze
|
| 125 |
+
rows = []
|
| 126 |
+
for rel in REAL_PDFS:
|
| 127 |
+
path = (ROOT / rel).resolve()
|
| 128 |
+
if not path.exists():
|
| 129 |
+
continue
|
| 130 |
+
pdf_ = pdfium.PdfDocument(str(path))
|
| 131 |
+
with pdfplumber.open(path) as pl:
|
| 132 |
+
for n, page in enumerate(pl.pages):
|
| 133 |
+
ref = _norm(page.extract_text() or "")
|
| 134 |
+
if len(ref) < 50 or len(rows) >= max_pages:
|
| 135 |
+
continue
|
| 136 |
+
img = pdf_[n].render(scale=dpi / 72).to_pil()
|
| 137 |
+
t0 = time.time()
|
| 138 |
+
blocks, _ = analyze(img, rec)
|
| 139 |
+
pred = _flat(blocks)
|
| 140 |
+
rows.append({"file": path.name, "page": n + 1, "ref_chars": len(ref),
|
| 141 |
+
"cer_reading_order": round(cer(pred, ref), 4), "word_f1": round(word_f1(pred, ref), 4),
|
| 142 |
+
"seconds": round(time.time() - t0, 2)})
|
| 143 |
+
pdf_.close()
|
| 144 |
+
agg = {"pages": len(rows), "dpi": dpi,
|
| 145 |
+
"mean_word_f1": round(float(np.mean([r["word_f1"] for r in rows])), 4) if rows else None,
|
| 146 |
+
"median_word_f1": round(float(np.median([r["word_f1"] for r in rows])), 4) if rows else None,
|
| 147 |
+
"mean_cer_reading_order": round(float(np.mean([r["cer_reading_order"] for r in rows])), 4) if rows else None}
|
| 148 |
+
return {"summary": agg, "pages": rows}
|
| 149 |
+
|
| 150 |
+
|
| 151 |
+
def run(model, output, line_count=2000, page_count=40, real_pages=40):
|
| 152 |
+
torch.set_num_threads(4)
|
| 153 |
+
out = Path(output)
|
| 154 |
+
out.mkdir(parents=True, exist_ok=True)
|
| 155 |
+
rec = Recognizer(str(model))
|
| 156 |
+
report = {"model": str(model), "parameters": sum(p.numel() for p in rec.net.parameters()),
|
| 157 |
+
"weights_mb": round(Path(model).stat().st_size / 2 ** 20, 2)}
|
| 158 |
+
report["lines"] = lines(rec, line_count)
|
| 159 |
+
print(json.dumps({k: {m: v for m, v in r.items() if m != "examples"} for k, r in report["lines"].items()}, indent=1))
|
| 160 |
+
report["pages"] = pages(rec, page_count)
|
| 161 |
+
print(json.dumps(report["pages"], indent=1))
|
| 162 |
+
report["real_pdf_pages"] = real_pdfs(rec, max_pages=real_pages)
|
| 163 |
+
print(json.dumps(report["real_pdf_pages"]["summary"], indent=1))
|
| 164 |
+
(out / "report.json").write_text(json.dumps(report, indent=2))
|
| 165 |
+
return report
|
docparse/cli.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Command line: docparse extract | train-ocr | bench | ui."""
|
| 2 |
+
import argparse
|
| 3 |
+
import json
|
| 4 |
+
import sys
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
def main(argv=None):
|
| 9 |
+
ap = argparse.ArgumentParser(prog="docparse")
|
| 10 |
+
sub = ap.add_subparsers(dest="cmd", required=True)
|
| 11 |
+
|
| 12 |
+
e = sub.add_parser("extract", help="extract text, tables and structure from a document")
|
| 13 |
+
e.add_argument("files", nargs="+", help="PDF, image, DOCX/DOC, XLSX/XLS, CSV, PPTX ...")
|
| 14 |
+
e.add_argument("--format", default="md", choices=["md", "json", "text"])
|
| 15 |
+
e.add_argument("--ocr", default="auto", choices=["auto", "always", "never"],
|
| 16 |
+
help="auto: OCR only PDF pages that have no text layer")
|
| 17 |
+
e.add_argument("--ocr-images", action="store_true", help="also read text inside pictures embedded in documents")
|
| 18 |
+
e.add_argument("--model", help="OCR weights (default artifacts/docparse/ocr.pt)")
|
| 19 |
+
e.add_argument("--dpi", type=int, default=200, help="render resolution for scanned PDF pages")
|
| 20 |
+
e.add_argument("--password", help="password for encrypted PDFs")
|
| 21 |
+
e.add_argument("-o", "--output", help="output file (one input) or directory (several inputs)")
|
| 22 |
+
|
| 23 |
+
t = sub.add_parser("train-ocr", help="train the line recognizer from scratch on synthetic lines")
|
| 24 |
+
t.add_argument("--run", required=True)
|
| 25 |
+
t.add_argument("--rounds", type=int, default=4)
|
| 26 |
+
t.add_argument("--lines-per-round", type=int, default=120_000)
|
| 27 |
+
t.add_argument("--batch-size", type=int, default=48)
|
| 28 |
+
t.add_argument("--lr", type=float, default=2e-3)
|
| 29 |
+
t.add_argument("--seed", type=int, default=0)
|
| 30 |
+
t.add_argument("--threads", type=int, default=4)
|
| 31 |
+
t.add_argument("--resume", action="store_true")
|
| 32 |
+
|
| 33 |
+
b = sub.add_parser("bench", help="benchmark OCR on held-out lines, synthetic pages and real PDF pages")
|
| 34 |
+
b.add_argument("--model", default="artifacts/docparse/ocr.pt")
|
| 35 |
+
b.add_argument("--output", required=True)
|
| 36 |
+
b.add_argument("--lines", type=int, default=2000)
|
| 37 |
+
b.add_argument("--pages", type=int, default=40)
|
| 38 |
+
b.add_argument("--real-pages", type=int, default=40)
|
| 39 |
+
|
| 40 |
+
u = sub.add_parser("ui", help="local browser UI: drop a file, get Markdown and JSON")
|
| 41 |
+
u.add_argument("--port", type=int, default=7862)
|
| 42 |
+
u.add_argument("--host", default="127.0.0.1")
|
| 43 |
+
u.add_argument("--model")
|
| 44 |
+
|
| 45 |
+
args = ap.parse_args(argv)
|
| 46 |
+
if args.cmd == "extract":
|
| 47 |
+
from .extract import extract, to_markdown, to_text
|
| 48 |
+
for f in args.files:
|
| 49 |
+
doc = extract(f, ocr=args.ocr, ocr_images=args.ocr_images, model=args.model, dpi=args.dpi,
|
| 50 |
+
password=args.password)
|
| 51 |
+
body = (json.dumps(doc, indent=2, ensure_ascii=False, default=str) if args.format == "json"
|
| 52 |
+
else to_markdown(doc) if args.format == "md" else to_text(doc) + "\n")
|
| 53 |
+
if args.output and len(args.files) > 1:
|
| 54 |
+
out = Path(args.output)
|
| 55 |
+
out.mkdir(parents=True, exist_ok=True)
|
| 56 |
+
(out / (Path(f).name + "." + args.format)).write_text(body, encoding="utf-8")
|
| 57 |
+
elif args.output:
|
| 58 |
+
Path(args.output).write_text(body, encoding="utf-8")
|
| 59 |
+
else:
|
| 60 |
+
sys.stdout.write(body)
|
| 61 |
+
for w in doc["warnings"]:
|
| 62 |
+
print(f"warning: {Path(f).name}: {w}", file=sys.stderr)
|
| 63 |
+
elif args.cmd == "train-ocr":
|
| 64 |
+
from .ocr.train import train
|
| 65 |
+
_, info = train(args.run, args.rounds, args.lines_per_round, args.batch_size, args.lr, args.seed,
|
| 66 |
+
args.threads, args.resume, log=lambda m: print(m, flush=True))
|
| 67 |
+
print(json.dumps(info["history"][-1], indent=2))
|
| 68 |
+
elif args.cmd == "bench":
|
| 69 |
+
from .bench import run
|
| 70 |
+
run(args.model, args.output, args.lines, args.pages, args.real_pages)
|
| 71 |
+
elif args.cmd == "ui":
|
| 72 |
+
from .ui import serve
|
| 73 |
+
serve(args.port, args.host, args.model)
|
| 74 |
+
return 0
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
if __name__ == "__main__":
|
| 78 |
+
sys.exit(main())
|
docparse/data/corpus_test.txt
ADDED
|
@@ -0,0 +1,325 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
Type C effects (those effects related to an increased frequency of 'spontaneous ' disease) are difficult to study, however, and continue to pose a pharmacoepidemiological challenge
|
| 2 |
+
DESIGN : Case-control association testing
|
| 3 |
+
The app/ website is difficult to use and the UI is as good as a brick. Windows 98 is easier to use. You can't contact customer support via the phone, at all. If you can't log in you can't get to their email to contact them and when you finally hear back it's "violated terms of service" and will not elaborate. IV read the TOS and there's nothing I'm doing wrong. Can't use the app/website on multiple devices or it will lose its ****. Relies on community mangers to help solve claims but it dosent help at all. I had to google a phone number and email to put in a support ticket while being locked out of my account. The phone goes to an automated voice that only tells you, to refer to their help tab that has useless information. LinkedIn needs competition because this is a huge pain to deal with. I strongly suggest you look for an alternative to LinkedIn.
|
| 4 |
+
Comparison of the frequency of lymphocyte expression of PIBF in pregnant versus non-pregnant women were made
|
| 5 |
+
These results clearly show that the short form acts as a dominant negative inhibitor through the formation of inactive heterodimers, resulting in an inhibition of Janus kinase 2 (JAK2) activation
|
| 6 |
+
Very few autonomic tests have been done on pediatric patients thus far
|
| 7 |
+
PARTICIPANTS : The 562 participating adolescents represented 92% of students in mandatory classes
|
| 8 |
+
Both current and previous prescribing for sore throat increase reattendance
|
| 9 |
+
D19S574 showed a significant effect (P=.015) in Caucasian patients with MS due to the increased frequency of a single allele (P=.002)
|
| 10 |
+
A key feature of the method is isolation and reverse transcription of mRNA followed by PCR amplification of lysosomal alpha-glucosidase cDNA with M13-extended primers
|
| 11 |
+
It is concluded that ENS is more effective for immediate relief of myofascial trigger point pain than EMS, and EMS has a better effect on immediate release of muscle tightness than ENS
|
| 12 |
+
An increase in the SDS concentration reduces the number of different species present in micelles
|
| 13 |
+
These levels were determined in patients undergoing three types of therapy : non-in vitro fertilization (IVF), IVF-embryo transfer (ET), and frozen ET
|
| 14 |
+
In all complete honesty, LinkedIn as a great app and it's net work is good, but seriously, the amount of emails that you get from this company probably emits as much greenhouse gas as mining bitcoin. It's in my opinion, one of the biggest email cloggers I have yet to experience. I have the rest of my life to see one that is worse, but challenge accepted, I guess.I've been on this app since I was under 18 and I'm 27 now. I have spent many an hour clearing these LinkedIn emails, and it's to the point where I fully understand that it could potentially be causing a detriment of heat to the earth due to the amount of emails they send out to literally anybody who is on planet earth and has an account. You will get carpal tunnel from swiping these emails into archive.
|
| 15 |
+
There were no significant changes in AED levels or electroencephalographic findings
|
| 16 |
+
A priori stratification by APOE genotype identified 27 families that had at least 1 member with AD whose genotype did not contain an APOE*4 allele
|
| 17 |
+
In order to understand and model cochlear processes involved in TEOAE generation, it needs to be known whether the presence of normal hearing leads automatically to generation of TEOAEs
|
| 18 |
+
A double-blind, crossover design was used to compare the effects of methylphenidate versus placebo on an electroencephalogram, AED levels, and the CPT
|
| 19 |
+
Signals have qualitative and quantitative aspects
|
| 20 |
+
I log in to see that my account was banned and have to verify??? That's never happened to me before and there's no reason stating what "suspicious activity" is being done? Even to get help as in a support ticket, or via email, you have to sign in but I CAN'T because my account is banned for no apparent reason! You guys need to seriously state the specific reasons as to why accounts are banned because I haven't done anything but use your guys clarify app to get verified and also apply to jobs and post my degree so what's the reason I'm so called "banned" for? Your customer service is horrible as well.. they're should definitely be an easier way to contact someone other than being a member because you ban us for no reason and it's impossible getting a hold of you guys. At that, it doesn't even state WHEN our accounts will be off banned mode or how long it takes for them to verify. It's ridiculous. I wouldn't recommend this app.
|
| 21 |
+
BCR-ABL+ cells treated with ceramide also showed a rapid and sequential increase in the tyrosine phosphorylation of p210 (BCR-ABL), p46-56SHC and p120Cbl
|
| 22 |
+
These initial observations are an encouraging step toward the description of non-HLA genetic susceptibility to MS
|
| 23 |
+
Horrible interface
|
| 24 |
+
I downloaded this app on my phone just so i could advise everyone not to use this website. For a senior project in my AP english class, everyone needs to create a LinkedIn account. Everything was going fine and I had spent about 3 hours working on the profile and only had about an hour or less of work left to do. The next day, I go to log in and there is an error message that says I need to submit ID due to suspicious activity. I talk to my teacher and this seems to be a problem with many other students. She contacts LinkedIn and they said it was due to a mass login on the same internet, and it will be fixed soon. The next day, very few students were still locked out, including me. I submitted my drivers license. The day after that, I was one of two students locked out. Skip a few days (3), and they FINALLY respond to me saying my account is permanently banned. I am now drastically behind in my class and this program is going to ultimately lead to my failure in this college class. I hope you guys fix your support team and maybe look at issues a little deeper before banning people.
|
| 25 |
+
Home care workers face an increasing risk for workplace violence
|
| 26 |
+
DESIGN : Cross sectional survey
|
| 27 |
+
Histopathological findings included plaque-like encasement of the leaflets and chordal structures with intact valve architecture
|
| 28 |
+
They have been combined with a nonlinear regression analysis method and tested on both simulated and real sets of biomedical MRS data selected with respect to the main problems usually encountered in quantifying biomedical MRS, specifically `` chemical noise, '' resulting from overlapping resonances, and baseline distortion
|
| 29 |
+
It's driving me bananas but if you search for a job and put one of the filters on (company, date posted, industry, function etc) sometimes if you put a second filter on, it will remove the first filter. Please please please fix this. I have an iPhone 13 Pro Max and am running the newest IOS software. It is driving me crazy.Also!! When you go to select "company" there are many companies that you are able to choose from or you can search for one, you should make one of the defaults be the company the person is currently at or ones that they search a lot not random jobs. It doesn't make sense why the default ones aren't based on frequency. I don't understand how such a powerful and renown company can have so many bugs with their searching features. Hire more engineers. It's unfortunate there is nothing like LinkedIn because I would love to use anything that doesn't make me want to rip my hair out.
|
| 30 |
+
Racist platform - sells your ID on the Dark Web
|
| 31 |
+
The production of 1 greatly accelerates the decomposition of peroxynitrite to give nitrate
|
| 32 |
+
Great app but...
|
| 33 |
+
However, some recovery was seen for a change in the medium (growth medium to conditioned medium) up to 5-6 h postirradiation
|
| 34 |
+
Love LinkedIn! But the app...
|
| 35 |
+
Love LinkedIn - hate this one tiny bug
|
| 36 |
+
These results indicate that the present parthenogenesis mechanism shares the same characteristics as that achieved by post-GVBD activation in the suppression of polar body formation as a key means for successful starfish parthenogenesis
|
| 37 |
+
MAIN OUTCOME STUDY FACTORS : Cerebrovascular accident first-week fatality rate ; age- and sex-related incidence
|
| 38 |
+
A dose-response was demonstrated for both opioids
|
| 39 |
+
Horrible Interface
|
| 40 |
+
Atrocious customer service
|
| 41 |
+
I swear this site has provided me with so much stress just trying to set it up. I didn't dropped out of college and I left my job but they are both in my interests and I cannot change them. Whenever I open the app it tells me to finish updating my profile but I don't have anymore information to add. There's no way to say no. When I tried to leave things blank because they don't apply to me I couldn't. I got a notice that I had shown up in a search but when I opened the notification I was taken to a page within the app that did not work without a laptop. Why would this happen on the mobile app? It makes no sense. I've made all the connections I want to for now but every time I open the app I have to skip through the page to make connections with random people I don't know. This app and the website are infuriating to attempt to use. I use all kinds of other social media that work at least decently. This platform is by far the most angering to use. Maybe the team is overworked or has no time to fix these issues, but they are too glaring and long lasting to still be around.
|
| 42 |
+
Successful arthrodesis was achieved in all five feet in patients with rheumatoid arthritis
|
| 43 |
+
After adjusting for stage, factors such as poverty, other socioeconomic conditions, and treatment did not further explain the remaining survival deficit
|
| 44 |
+
Few data exist as to whether there are clinical differences between prostate cancers that are inherited and those that occur in the general population
|
| 45 |
+
The study was controlled for other factors that could influence the parasite 's prevalence, such as the availability of intermediate hosts
|
| 46 |
+
DESIGN : A complete sequence analysis of the BRCA1 coding sequence and flanking intronic regions was performed in 798 women in a collaborative effort involving institutions from the United States, Italy, Germany, Finland, and Switzerland
|
| 47 |
+
These results are consistent with two pools of DSBs (or cells), each with their own t (rep)
|
| 48 |
+
Miracidia clearly preferred SCW from their specific host snail versus other sympatric snail species and did not respond to water conditioned with fish, tadpoles, or leeches
|
| 49 |
+
However, the association of parvovirus and fetal pleural or pericardial effusions has not been reported
|
| 50 |
+
Terrible tech support as a professional platform
|
| 51 |
+
My LI app will no longer run, I'm forced to upgrade the app, but my iPhone doesn't support the new minimum de for the latest app. Sad LI made that decision to not support earlier sw versions :( and being in transition spending $1k on new phone isn't the smartest idea right now. Laptop only going forward :(Still abandoned- their support said delete app and reload, there was hope as when I was reloading it said that it would load the sw that was compatible with my phone and that some features may not work. Ha, who knew the feature that wouldn't work was the app itself... they have a screen that comes up stating "update for the latest sw" but you can't get out of that loop, my phone doesn't support latest iPhone sw so it can't get the latest LI download. A silly loop they could correct if they wanted, I guess they don't want to, they closed my case because I didn't respond back in the hour, like all I have to do is mess with this app, ugh. Now you know why I feel abandoned...
|
| 52 |
+
Subjectively, patients improved from an average of 0.69 before arthrodesis to 4.89 after arthrodesis
|
| 53 |
+
Out of all the Options
|
| 54 |
+
RESULTS : Prevalence of confirmed infection was 2.6% (95% confidence interval 1.6% to 3.6%) in all women
|
| 55 |
+
The newest update has them limiting your preferences for your job search an forcibly showing you state wide jobs like that helps. If someone like myself has set up job preferences why take them away and why start showing jobs to them that are outside of their preferences like a job search isn't already hard enough and tiring enough. Now I'm every time I open the app instead of being able to scroll through the recommendations I have to create different searches because the jobs recommended to me are 8+ hours away and don't interest me in the slightest. Just because I live in a state doesn't mean I'm comfortable commuting far and doesn't mean I'm trying to relocate to the opposite side of it. So who's dumb idea was it to add that useless and annoying feature that shows jobs to people outside their now very limited preferences. I don't want to be shown state wide jobs and according to the customer service rep it's a feature I can't manually get rid of. Dumbest update to date.
|
| 56 |
+
I downloaded the app again because I thought it would be easier to use on my phone. I've been using it for about 30 minutes and it has crashed 3 times. It crashed while I was sending a request to connect. I was attaching a message to this request because the person doesn't know me. Now, It has sent the request and I can't finish my message. I don't know if it attached what I typed already or not. Given that LinkedIn is a tool to help people network and make professional connections, this is more than frustrating. Now, to reach out to this person, I would have to pay for a premium account and use "in mail." I'll not be using the app anymore which is a serious inconvenience. I stopped using the Linked In app years ago because it would lead to accidental connection requests while scrolling through searches or suggested connection.
|
| 57 |
+
Here we show that individual amino-acid enantiomers from Murchison are enriched in 15N relative to their terrestrial counterparts, so confirming an extraterrestrial source for an L-enantiomer excess in the Solar System that may predate the origin of life on the Earth
|
| 58 |
+
I used a linked in free profile for a while and when I was on the job market decided to try premium. I had it for a few months past the free trial, used it multiple times a week, but soon realized I didn't see any difference in the experience. No better prospects, never got any interest in my profile even though I opted to let recruiters see me. I even optimized my profile based on suggestions and no discernible difference in engagement or connections. Decided to cancel premium but then found out that I couldn't go back to my free profile (perhaps even they know there is no difference!) I had to erase my whole profile just to stop paying for a service that offered no real service! It is annoying because now I can't even use LinkedIn links (like when someone send me a job I might be interested in) without making a new free profile.
|
| 59 |
+
Results of early rehabilitation seem to be influenced by duration and etiology of deafness, experience with hearing aids, and other factors
|
| 60 |
+
The article stresses behavioral treatment modalities that eliminate the debilitating phobia
|
| 61 |
+
When I first joined LinkedIn I had support help me consolidate two accounts and that wasn't an issue. Then because I debated some racists and debunked their lies, LinkedIn decided I was the guilty party and suspended my account. They demanded a copy of my ID front and back and/or passport. My identity could've been verified through my contacts who are high level attorneys, CEOs and even the Chamber of Commerce of which I am a member and LinkedIn sees that. They refused to reinstate my account. They're racists as revealed by an article in NY Times in 2019! I then created another account and rebuilt my profile. After a while LinkedIn seeing I was getting a lot of traction to my posts restricted my account again this time claiming I had two accounts AND I NEEDED to provide a passport or ID again! These people are insane! Suddenly my credit report warned that my email address was compromised and was found on the dark web! Guess who did this? Yes, LinkedIn. This company is a racist lying bunch of thieves and you should really avoid it at all cost. It's no longer the professional platform it once was.
|
| 62 |
+
CONCLUSION : Calcium supplementation during pregnancy is associated with lower systolic blood pressure in the offspring, particularly among overweight children
|
| 63 |
+
The absolute difference was 0.44 percent, with a one-sided 95 percent upper boundary of 1.49 percent, which exceeded the prespecified upper limit of 0.40 percent to indicate equivalence in 30-day mortality between the two regimens
|
| 64 |
+
Needs improvement
|
| 65 |
+
Primary efficacy endpoint was the percentage improvement in Unified Parkinson 's Disease Rating Scale (UPDRS) motor score
|
| 66 |
+
< TO_SEE > CONTEXT : There is a substantial risk of a second cancer for persons with hereditary retinoblastoma, which is enhanced by radiotherapy
|
| 67 |
+
One step forward, ten steps back
|
| 68 |
+
INTERPRETATION : G-CSF treatment was associated with improved clinical outcome of foot infection in diabetic patients
|
| 69 |
+
Social Connect NOT for CAREER seekers or EMPLOYMENT hopefuls
|
| 70 |
+
Will not save profile picture
|
| 71 |
+
INTERVENTION : After random assignment to 1 of 3 groups, the adolescents listened to a standardized audiotape depiction of an office visit during which they heard a physician who assured unconditional confidentiality, a physician who assured conditional confidentiality, or a physician who did not mention confidentiality
|
| 72 |
+
Updating my review (Jan 15, 2022): Everything below PLUS app freezes when trying to post or upload a photo. (Yes, I deleted and reinstalled.) No such thing as tech help from what I can see. Just consumer forums and FAQs. Down to 1 star and deleting from my phone. --I used to enjoy this app, but it seems that it is getting less and less user-friendly on iPad and iPhone. For example, the landing page and profile page do not mirror the most important features that are on the laptop version. It would be nice to be able to easily view my recent updates and make changes to all of my profile sections--not just certain ones--from the app. A new thing I've noticed now is that some articles or links can't be opened in browser or forwarded through email/text. The "forward" arrow icon pops up with a "cancel" message instead and no other option. So weird. Sadly, the "app support" is just a forum of somewhat useless info, which is why I'm posting here. Hopefully the designers will read everyone's feedback on App Store and correct these issues in next update.
|
| 73 |
+
A subchorial placental hematoma, which detected as a subchorial placental lucencies by ultrasonography, can be a cause of reversible nonimmune hydrops fetalis
|
| 74 |
+
The author presents background information, including incidence and etiology
|
| 75 |
+
FAMILIES : A total of 74 North American families with hereditary prostate cancer
|
| 76 |
+
Cocaine-positive women presented at an earlier gestational age (32 weeks ', (24-34) vs. 33 weeks ', (23-36), p = 0.02) and had a significantly longer membrane rupture to delivery interval than women with a negative urine drug screen (174 hr, (6-475) vs. 33 hours (1-833), p = 0.01)
|
| 77 |
+
In contrast, purified primary normal progenitors maintained under the same conditions die rapidly
|
| 78 |
+
This platform is no longer useful for finding a job. The app does all of what it can to keep the user locked in to the feed section. If I'm reviewing a job and my screen lock becomes active (or view something off of the app), when I return to the app, I am immediately taken to my feed. When I go back to the job section, the job I was reviewing is no longer visible. I'm sure it's not deleted. It's just hard to tell which one I was viewing, especially if you're searching for similar jobs. It's like as soon as you put your phone down, you lose place in line. It creates nothing but wasted time.For anyone who has experienced a layoff, unemployment is a lot of work and it can easily wear you out. The last thing a person needs is a tool that slows them down. Of course a person can look elsewhere for jobs but why even have a job section if it has basic flaws.
|
| 79 |
+
Neutrophils from the peripheral blood of these participants and from healthy controls were stimulated with opsonised zymosan, and superoxide production was measured by a spectrophotometric assay (reduction of ferricytochrome C)
|
| 80 |
+
CONCLUSIONS : Using logistic regression analysis, we provide a method for evaluating the probability of a woman 's carrying a deleterious BRCA1 mutation for a wide range of cases, which can be an important tool for clinicians as they incorporate genetic susceptibility testing into their medical practice
|
| 81 |
+
The effects of alanine and glycine substitution for tryptophan upon the species heterogeneity of gramicidin A analogs incorporated into SDS micelles have been investigated
|
| 82 |
+
This article discusses dental anxiety and phobia
|
| 83 |
+
College students spend 4 hr weekly on the PM & # 38 ; R consultation service as team members under resident supervision
|
| 84 |
+
There was also a slightly higher rate of intracranial hemorrhage with the double-bolus method
|
| 85 |
+
There were no differences among groups in sedation scores or side effects
|
| 86 |
+
I've wasted my time for years with Indeed trying to search for jobs, and most of the wasted time is related to having to get through the pile of unrelated jobs thrown at me without any relevance to my job search. It doesn't matter how deeply I attempt to tweak my search, get rid of unrelated jobs or even supply comments to this company as to why their jobs are irrelevant - they keep on sending jobs that have absolutely no connection to my search criteria. Indeed could apply a significant fix by limiting job results to only those which have the exact match in the job title, but that's not an option. Why not? Because Indeed gets paid for the number of times a job shows up in a search, not whether it has no relevance. "Failed algorithms which waste job seekers time" - I suspect that this is the vision statement for Indeed. I depend on LinkedIn instead, and so should everyone else: Same jobs, but less wasted time and effort.
|
| 87 |
+
We conducted a survey and audit of thermal equipment use in very low-birth-weight infants in five Ohio neonatal intensive care units (NICUs) to document regional practice
|
| 88 |
+
INTERPRETATION : There is no treatment of choice for early-stage cervical carcinoma in terms of overall or disease-free survival
|
| 89 |
+
Responding to recruiters new feature is terrible
|
| 90 |
+
In our study, we specifically analyzed the role played by advanced maternal age and sonographically discovered abnormalities in the detection of autosomal trisomies
|
| 91 |
+
343 eligible patients were randomised : 172 to surgery and 171 to radical radiotherapy
|
| 92 |
+
For pain relief, ENS was significantly better (P & # 60 ; 0.05) than EMS ; but for the improvement of ROM, EMS was significantly better (P & # 60 ; 0.05) than ENS
|
| 93 |
+
An account is basically required by potential employers if you're applying to white collar jobs, but is incredibly difficult to sort thru or actually get a response from companies- especially if you are applying thru linkedin itself. Would recommend only using the app to find companies that sound interesting, vetting them thru glassdoor, then applying via the company website itself.Suggestion 1 for better UX: limit applications to 100 per job with job listings closing automatically upon hitting 100 apps received. I am tired of seeing open job listings with 100+ applicants because i KNOW that the hiring managers are only skimming the top 5% of applicants from the pile and then underpaying them for their experience. Suggestion 2 for better UX: Years of required experience being listed as a filter within job listings search. I am not interested in a job that pays entry level wages or says "fast paced environment" and then requires 5-7 years of experience AND a bachelors.
|
| 94 |
+
Chromosome 12 gave the strongest and most consistent results with a peak multipoint MLS of 3.5, suggesting that this region contains a new susceptibility gene for AD
|
| 95 |
+
For spontaneous major haemorrhage (not procedure related), 1-year life-table estimates were 0.74% (0.43-1.1) in the 160 mg aspirin group and 1.4% (0.94-1.8) in the 3 mg warfarin with 80 mg aspirin group (p = 0.014 log rank on follow-up)
|
| 96 |
+
linkedin (and the rest of social media) has become terrible
|
| 97 |
+
Only sort of useful
|
| 98 |
+
The present study set out to establish in a large sample if any ears could be found that lacked TEOAEs despite normal hearing threshold levels (HTL)
|
| 99 |
+
These results provide electrophysiological evidence for early semantic activation and later phonological encoding
|
| 100 |
+
Hallux length was well maintained after surgery with bone grafting, but it was more difficult to obtain the alignment goals
|
| 101 |
+
Demographic information, hours from rupture of membranes to delivery, gestational age, and birth weight at delivery were compared using Fisher 's exact, Mann-Whitney U, and randomization tests where appropriate
|
| 102 |
+
In the case of the 12 base-pair DNA duplex, torsion-angle molecular dynamics had a success rate of 52% while Cartesian molecular dynamics and metric-matrix distance geometry always failed
|
| 103 |
+
Notification Issues
|
| 104 |
+
Very buggy app
|
| 105 |
+
What is going on !!!!
|
| 106 |
+
These findings suggest growth factor deprivation and treatment with TNF-alpha or ceramide trigger different initial events both of which can lead to apoptosis in factor-dependent hematopoietic cells
|
| 107 |
+
I was just trying to apply to a job on LinkedIn. They said to contact the employer by email and provided the email. When I press and hold LinkedIn does not allow you to select text to copy and paste into your mail. The email is also not a link I can click on to email. I unfortunately have to manually type in the long email letter by letter going back and fourth. Please fix this. I was also recently searching for jobs and reading a number of job descriptions. Many were in my field but did not apply to me. I would like to be able to hide them so I don't see them in the future. Don't see a way to do this. I only see Apply and Save buttons; no hide. When viewing a clients portfolio web link and clicking on it nothing happens within LinkedIn. This link should open up in Safari; I can not copy/paste it into Safari either and have to go back and fourth typing letter by letter.
|
| 108 |
+
The urine screen did not influence management decisions
|
| 109 |
+
Granulocyte-colony stimulating factor (G-CSF) increases the release of neutrophils from the bone marrow and improves neutrophil function
|
| 110 |
+
The app for fake people
|
| 111 |
+
The patient 's ability to wear shoes improved from 0.87 to 3.1
|
| 112 |
+
106, 252-270, 1986) and double-strand break (DSB) (J
|
| 113 |
+
Longer duration of illness (> 5 days) was associated with increased return within six weeks (hazard ratio 2.90, 1.70 to 4.92)
|
| 114 |
+
Interaction effects between DR2 and chromosome 19q13.2 or MBP in MS susceptibility were not apparent
|
| 115 |
+
I need more dots on my die!
|
| 116 |
+
App crashes and crashes again.
|
| 117 |
+
CONCLUSION : Methylphenidate is effective in treating children with epilepsy and ADHD and safe in children who are seizure free
|
| 118 |
+
< TO_SEE > FINDINGS : G-CSF therapy was associated with earlier eradication of pathogens from the infected ulcer (median 4 (range 2-10) vs 8 (2-79) days in the placebo group ; p = 0.02), quicker resolution of cellulitis < (7 (5-20) vs 12 (5-93) days ; p = 0.03), shorter hospital stay (10 (7-31) vs 17.5 (9-100) days ; p = 0.02), and a shorter duration of intravenous antibiotic treatment (8.5 (5-30) vs 14.5 (8-63) days ; p = 0.02)
|
| 119 |
+
This study was designed to determine and compare the dose-response characteristics, speed of onset, and relative potency of single-dose epidural fentanyl (F) and sufentanil (S) for postoperative pain relief
|
| 120 |
+
LinkedIn... I love your platform. I have already posted a comment on the lack of a sort by "recent" function on for iOS. When I'm on my phone, the work around is to go to your mobile site, then request the desktop site, then select "sort by: recent". The most recent update you just pushed out now has "lives" or something that feels like a clone of Instagram/YouTube/Snapchat/Facebook, etc. I do not subscribe to these platforms because I believe LinkedIn is the only social media platform of any substance. There is quality content and networking happening everyday. Why did your newest update add a cookie-cutter "lives" function and not a simple "sort by:recent" function. Why am I seeing 3 week old posts with recent posts scattered about. Totally ridiculous... The one platform I thought was doing a service to a community of professional people is slowly becoming just another social media platform.
|
| 121 |
+
Useful but opportunities for improvement
|
| 122 |
+
High-dose fluoride treatment did not improve, but decreased, bone strength in rabbits, even in the absence of impaired mineralization
|
| 123 |
+
RESULTS : Mean follow up time was similar (antibiotic group 1.07 years, other two groups 1.03 years)
|
| 124 |
+
Dye labeled primers are used for cycle sequencing and an ABI PRISM 377 DNA sequencing system for analysis
|
| 125 |
+
Not able to access on cellular, only WiFi
|
| 126 |
+
Fluctuations in ambient temperature are inversely correlated to changes in AFI
|
| 127 |
+
Messaging has become terrible and extremely frustrating
|
| 128 |
+
After using the application for quite a while that I have added too many connections that I have never talked to. So recently I was trying to clean up those connections. Because I didn't find any option to delete all unused connections all at once so I just manually deleted them. All of sudden I received an system notification that I was using a third party software and restricted for log in in 1 hour. I was so frustrated that I never used anything like that and I was just simply cleaning up my unused connections. I mean if LinkedIn has the option to let me delete them all at once then I don't need to manually delete one by one and create a 'high volume' as they claimed. It is the inaccurate algorithms that mark me as something called 'using the the third party software'. In fact I don't even know there exists this kind of third party software. The customer representative was like this is a done deal that you are using the 3rd party software and any tickets regarding to this would be closed automatically. So LinkedIn made a mistake that they don't admit it. And the customer has no right to point it out! See this is the way how they treat their customers!
|
| 129 |
+
LinkedIn closed my account for absolutely no reason.After I've updated the Linkedin App, I was asked to re-login, but instead of logging into my existing account, it created a new profile for me, completely blank. I was confused at first but was still able to search up my old account and thought of it as a tech glitch. I reached out to the service team and waited a few days before reaching out again, after they said they'll transfer my case to another team. By now I can no longer find my old account and am panicking, since it contains all my connections and the profile that I've built long and hard for.The team responded saying my account is restricted permanently for misrepresenting my identity, after I've provided my ID, and closed my case with no other details. Now I'm stuck with no information and a brand new account and no access to any of my past connections. I have offered the team to provide any relevant info to verify my past work/volunteering experiences, but I am just greatly disappointed by LinkedIn tech support.I've been patience since the start and comply with the rules, despite the great inconvenience that this has caused me work-wise, but I'm simply frustrated now that I may never get my account back for absolutely no reason. Apparently this has been an issue for many people and I don't understand why it continues to be one, being one of the most professional platform used by thousands.
|
| 130 |
+
LinkedIn is my preferred way to interact with business professionals. I do, however, wish that the site would allow a longer time to deactivate an account or allow one to restore their account within a year. I deactivated my account for a "spiritual hiatus" and attempted to set my account back up and was unable to retrieve it. I built a great network before and now it seems like I am not getting as many recommendations for people that I knew from college and other businesses. I'm sure my post sounds like a combination of great things and not so great things, but I do really appreciate the fact that the tool is much more professional than most social media outlets.
|
| 131 |
+
The effectiveness of treatment was assessed by conducting three measurements on each muscle before and immediately after treatment : subjective pain intensity ((PI) with a visual analog scale), pressure pain threshold ((PT) with algometry), and range of motion ((ROM) with a goniometer) of upper trapezius muscle (lateral bending of cervical spine to the opposite side)
|
| 132 |
+
Not able to open an app on my iPhone
|
| 133 |
+
LinkedIn was okay until I became a creator. It takes courage to post but over time, like 3-6 months, it becomes easier. At that point it becomes a lot more fun and now I look forward to using LinkedIn and this app every day. Not sure what to write? Use the 2 year, 2 day, or even 2 hour rule (credit Nicolas Cole). Just post what you would have benefited from had you known it 2 years ago, 2 days ago, or even 2 minutes ago!Developers: thanks for your work on the app!! First request: wish the scheduled posts could be edited. Second request: wish commenting was just better organized for conversations. Like replying to a reply to a reply. Also wish that the best answers/comments filtered to the top. Would draw you in as a user to know that the first comments are the most valued by the community (when there are dozens of comments and you don't want to sift through them all). Again thanks though for the great app!
|
| 134 |
+
MAIN OUTCOME MEASURES : Mean blood pressure and rate of high blood pressure of children
|
| 135 |
+
Chronological content feed - second attempt.
|
| 136 |
+
F 100 and 200 microg and S 20 and 30 microg all achieved VAS scores & # 60 ; 10 mm in all women with no differences in time to 50% reduction in VAS (mean 11-16 min) and no differences in duration of analgesia (mean 117-138 min)
|
| 137 |
+
I love LinkedIn, but would like to see an unlimited word count. There are so many times that I have a post that exceeds the allotted word count and it's frustrating. I could condense what i need to say most times, but sometimes I need to be able to say more than what LinkedIn allows. If the post is good, people will read it. If not, they don't have to read it and can just scroll on past. I'm sure if you took a poll from other LinkedIn users, you would find that most of them would agree that LinkedIn needs an unlimited word count. Don't limit it solely for the purpose of making sure people's posts are shorter. Give people the opportunity to add as much as they need to their posts. Sometimes you need to be able to add more to your post to properly say what you need to say because tightening your word count doesn't always cut it.
|
| 138 |
+
ONLY use for a self propped social site to highlight career accomplishments, this is in NO way a site assisting with career, employment, job, or training for such in any way. I don't believe anyone in it's history actually found or obtained relevant employment by association with this site. The app is disgraceful with countless & continuous problems, the functions, features & customizations are only there to frustrate since no real functionality, majority if not all content from being contacted will be not or scam derived with no reportable solution for removal. If you have the time to waste, money to flush for the Pro version yet love advertisement inserts along with pro paying versions, lack of responsive support services & never getting much in return then you Will absolutely adore & promote this bug infested nightmare...there's way better available if a serious job seeker, career hopeful & educated enough to avoid this mess individual or business looking for hires. Hope this fades from App Store listings & internet history quickly for everyone's sake, the world could only improve for it doing so.
|
| 139 |
+
The app is solid and very easy to use. My complaints are the content in the Feed and that the option "improve my feed" really offers no real options except adding new people to follow. It should be flexible and let you choose what you want to see more of OR less of. For example, in my feed I don't need to see every single time a connection "likes" something. I also remember I always used to see when my current connections connected to new people. I used to see those a lot every day. That was helpful info if a contact connected to someone new in my industry. It worked well in conjunction with the other "people I may know" info. I almost never see those types of updates anymore in my feed. Can you explain why the feed offers me zero flexibility to see the things that are most important to me?
|
| 140 |
+
Prior attendance with upper respiratory conditions was also associated with increased reattendance
|
| 141 |
+
There was no difference between groups in early return (13/238 (5.5%) v 27/437 (6%)), or complications (2/236 (0.8%) v 3/434 (0.7%))
|
| 142 |
+
LinkedIn is the prime social networking app for professionals. Main avenue for jobs and all that.I think here's some opportunities for improvement:1. Make a filter for company size when looking for jobs. 2. A lot of recruiters post contracts as "full-time" as a sneaky way to get more applicants since they consider a full time role 40 hrs/week. Make this differentiation even clearer by saying "Permanent Full-Time." I don't know how many contract roles I've seen saying it's Full-Time and luckily I read every description pretty carefully.3. Make an option for when Looking For Work is turned on, we can put more details for recruiters to see. I get hit up for temp/contracts all the time and have no interest in them. Recruiters don't bother reading profiles.4. It would be great to have an option to rate or leave feedback on recruiters when you are communicating with them. Just like you have automatic "Yes/Not interested" responses, there should be a way to leave feedback on recruiter accounts since so many recruiters are rude, ghost candidates, post things unrelated to your field/interests, solicit, etc.
|
| 143 |
+
Both right-sided and left-sided heart valves were involved
|
| 144 |
+
They do not show you the jobs if you don't pay them $34 per month. Which is expensive, because the same jobs are also on google too. They have scammers who impose as HR people, even doing fake interviews. If you report this to LinkedIn, they do not recognize it as a scam. I had an interview where I needed to perform a difficult 2 days test and an interview where the boss was racketeering me and harassing me. I also found more complaints about this particular company on google and Reddit but LinkedIn says, there is nothing wrong with them. Out of 200 applications, maybe 30 will download your file but only 11 will open your file. It means their platform is full of fake jobs. I have been applying for jobs straight to the companies and I know for sure they were not seeking for anyone but LinkedIn would post a job from those companies. Scam, a lot of scam. They just want your money. I was better off without them
|
| 145 |
+
Need Auto-Saving for Drafts
|
| 146 |
+
While I have benefitted from certain services that LinkedIn provides such as LinkedIn learning, LinkedIn no longer appears to serve the individual user as much as it serves corporate recruiting efforts and corporate advertising.I work for a large educational system, which by this time has hundreds and hundreds of individual users with LinkedIn profiles, and while manually searching through a number of these profiles, the software app recently tried to force me into signing an agreement that I would voluntarily limit future searches through my own organizational network for others to connect with to a certain undisclosed, arbitrary number chosen by LinkedIn without any further information from LinkedIn to me or face suspension of my account by the app. The software has not allowed me any further access to my own profile until I sign that agreement. I strongly recommend avoiding this glorified recruitment service in favor of other newer, more personable services which allow the user to be more in control of his or her own searches.
|
| 147 |
+
There was a significant correlation between the 2-, 3-, and 4-day mean temperature and AFI, with the 4-day mean being the most significant (r = 0.31, p & # 60 ; 0.001)
|
| 148 |
+
All together, 27 fetuses had this disorder, representing 28.7% (27 of 94) of all cytogenetic aberrations detected at our center over the same period
|
| 149 |
+
Hate speech from other LinkedIn members and employees
|
| 150 |
+
Generally good, job search alerts broken
|
| 151 |
+
Feel abandoned
|
| 152 |
+
Needs improvement FIX FIX FIX
|
| 153 |
+
Future studies should address the long-term consequences of prolonged use of fenfluramines
|
| 154 |
+
The infant presented with extreme pallor, hypotonia, hepatosplenomegaly, and ascites
|
| 155 |
+
In the absence of structural or karyotypic abnormalities, spontaneous resolution of the effusion portends for a successful pregnancy outcome
|
| 156 |
+
LinkedIn excels as a premier platform for professional networking and career advancement, proving indispensable for job seekers and employers alike. It offers a rich array of features and a user-friendly interface that facilitates dynamic interactions and professional development. However, it could significantly benefit from enhanced privacy settings, allowing users to conceal the start dates of their career experiences to combat the prevalent issue of ageism in the professional realm. Just like in the education section of a user's profile, this added flexibility would enable individuals to showcase their skills and experiences without the constraint of timelines, promoting a more inclusive professional environment. Overall, LinkedIn stands as a powerful tool for career progression, but adopting more nuanced privacy features would further its commitment to fostering equitable and diverse professional communities.
|
| 157 |
+
I'm a job seeker and I've been using LinkedIn to aid in my search. However, I'm afraid of missing a job opportunity as whenever I get a push notification I never hear any sound, unlike with my other apps. I always see a red number on my LinkedIn app widget showing me how many new notifications I have however I never hear any sound. All other sounds seem to be working which leads me to believe it's an app issue rather than a phone issue or operator error somewhere. Please help! Thanks!Also, unless I'm just missing it, I would LOVE the ability to select a custom notification sound within the app so I can identify a LinkedIn notification from all others. Thank you! Be safe!Oh! One thing I did forget to add is that I LOVE the section called "My Next Job Should Offer Me" where we're able to specify benefits we're asking for as well as the schedule we need. Oh and the Name Pronunciation feature is fantastic!!! Thank you!!!
|
| 158 |
+
DESIGN : Randomized controlled trial
|
| 159 |
+
I'm a huge fan of LinkedIn but Im unable to access anything right now!!! I am not unable to respond to my messages, or update anything....as soon as I try I am forced to open the app that at first said I didn't have an account so I reset my password..... even though I just had it open online.... now the app won't open because it's prompting me to fill out a page about my future job hopes and dreams that goes blank when I attempt to response. I don't understand why I can't use my online account anymore apps aren't dependable!! All I wanted to do is update my resume and read some articles! I apologize for my frustration I know it's an app thing! Please please please fix this issue!!!
|
| 160 |
+
The treatment was for 30 days
|
| 161 |
+
Changes to LinkedIn.
|
| 162 |
+
OBJECTIVE : To estimate the crude incidence rates of cerebrovascular accidents among the black residents of Harare
|
| 163 |
+
LinkedIn engages in profiling through barring certain searches
|
| 164 |
+
When the effectiveness of treatment was compared with that of the placebo group (group A), there was significant improvement in PI and PT in group B (P & # 60 ; 0.01) but not in group C (P > 0.05)
|
| 165 |
+
Please add an unlimited word count
|
| 166 |
+
Atrocious Algorithms
|
| 167 |
+
Our purpose was to systematically review data on brain serotonin neurotoxicity in animals treated with fenfluramines and the evidence linking fenfluramines to primary pulmonary hypertension (PPH)
|
| 168 |
+
The app does its job in working the same as desktop. However, the notifications only work 1/10 times. In fact, most times you try to click an external link that will open the app, the app opens on an all black or all white screen and stays there. You have to force close the app to have it load normal again but if you were trying to see what the notification was about, I hope you have good memory. The app also does not hold your place in the feed so clicking anything for example a url will open their in-app window and returning will refresh your feed. Since LinkedIn is built on an algorithm to keep you interested in the app, you most likely won't ever see that post again as it generates new items it thinks would be of interest. Lastly the other night I had spent 20 minutes writing a long message regarding business with another user. I was on my last sentence when the app suddenly crashed. If you're wondering, no, the app did not draft my messages and I had to type everything back out again. Overall, it does its job but be prepared for a lot of bugs. And the notifications Never work.
|
| 169 |
+
The addition of peroxynitrite to CO2 produces of the adduct ONOO-CO2- (1)
|
| 170 |
+
I recently transferred Service from Verizon to AT&T. The LinkedIn app only works on Wi-Fi it does not work connected to cellular service. I have gone through all of the troubleshooting steps with AT&T as well as Apple. I'm on an iPhone 13 Pro. Everyone has said it is not an issue on their end that I should contact the developer. Please advise if there is some thing I can do to make this app push through on cellular service. The app is up-to-date with what's available in the App Store for me. I use this app for business regularly and I'm currently only able to use it on my home computer or I also cannot access the LinkedIn website on a browser unless I am The app is up-to-date with what's available in the App Store for me. I use this app for business regularly and I'm currently only able to use it on my home computer or my phone but must be connected to Wi-Fi. The strange part is on my phone I also cannot access the LinkedIn website on a browser unless I am connected to Wi-Fi. Please advise. Thank you.
|
| 171 |
+
please read
|
| 172 |
+
Unprofessional customer service and inaccurate algorithms
|
| 173 |
+
Option to improve feed needs to be improved
|
| 174 |
+
OBJECTIVES : Obesity is an important clinical problem, and the use of dexfenfluramine hydrochloride for weight reduction has been widely publicized since its approval by the Food and Drug Administration
|
| 175 |
+
After being charged over $600 for a product I didn't realize I even bought due to their confusing interface I am being refused a refund and sent the same generic message about their no refund policy over and over again. I have used this website for years and am appalled by their dishonest practice. If you decide to ever use this website or app don't ever buy premium (it's useless) and don't ever put your card information on it or you might fall victim to what I'm going through. They make it too easy ti misinterpret something and buy something with an insane price tag without realizing what you're agreeing to. The app is also poorly designed as well. If you check Trust Pilot and the Better Business Bureau, they have a one star rating which is a huge red flag. Personally I would say avoid at all costs and don't ever give them any if your information except for the bare minimum. Don't expect a transparent app, business practice or to ever any kind of real conversation with customer service. Avoid at all cost but if you must use it Keep your expectations extremely low.
|
| 176 |
+
I have used LinkedIn for the last time. There is so much effort in building a LinkedIn profile and like other texh giants," they" have the ability to suspend one's account. It may be automatic, it may be AI or it may be someone clicking buttons. It could have been me making a joke, asking, "What? No fact chexking?" to a post where someone said, "Wrong is wrong, even if everybody is doing it and right is right, even if nobody is doing it." Whatever the reason, they have shown me repeatedly that this is not a platform that I can trust or depend on when I need it. I resisted giving them my government issued ID to regain access to my account, the last time this happened and eventually, "I Loved Big Brother," like at the end of 1984. I sent themmy ID and they still did not unlock my account. The promose was broken and was just a lie. They feel that they have power over us but it only works if we cooperate.
|
| 177 |
+
In a subset of 71 Ashkenazi Jewish women, only 2 distinct deleterious mutations were found : 185delAG in 17 cases and 5382insC in 7 cases
|
| 178 |
+
multilocularis from wild hosts has been demonstrated, the question of its long-term efficacy and other unresolved problems have to be addressed by consecutive studies before routine application can be recommended
|
| 179 |
+
Show me your papers! Repeatedly suspended.
|
| 180 |
+
Ropinirole monotherapy is an effective and well-tolerated therapeutic option for treatment of early Parkinson 's disease
|
| 181 |
+
Appetite suppressants-most commonly fenfluramines -increase the risk of developing PPH (odds ratio, 6.3), particularly when used for more than 3 months (odds ratio, > 20)
|
| 182 |
+
Here we show that the medial temporal region is active during encoding and that the hemisphere activated and the amount of activation depend on the type of stimulus presented (objects or words), whether the stimulus can be encoded for meaning (real objects and words versus nonsense objects and words), and task experience (first versus the second time a task is performed)
|
| 183 |
+
DESIGN : Retrospective case study in which clinical data were extracted from medical and pathological records
|
| 184 |
+
Screening all women aged & # 60 ; or = 25 and all women who had had two or more partners in the past year would have detected 87% (20/23) of infections
|
| 185 |
+
NOT for dedicated job seekers
|
| 186 |
+
However, except for genes in the HLA system, specific susceptibility loci are unknown or unconfirmed
|
| 187 |
+
Variation within the MBP locus on chromosome 18q23 showed no effect in MS
|
| 188 |
+
The study was terminated at 30 min if satisfactory analgesia was not achieved
|
| 189 |
+
Right temporal and cerebellar hemorrhages were present
|
| 190 |
+
Zalak Patel is a consummate PROFESSIONAL
|
| 191 |
+
BACKGROUND : Inadequate dietary intake of calcium and vitamin D may contribute to the high prevalence of osteoporosis among older persons
|
| 192 |
+
Freezes, Can't apply for jobs, links don't work! NEEDS THE TO BE FIXED!
|
| 193 |
+
Number one, if your application in the Apple IDs door would support a dialog box when selecting apps support I would be writing this in that dialog box and not under reviews. My review is based on the fact that Face ID is not used by LinkedIn. LinkedIn's responses leave your app signed in. I do not wish to do that, the information in the app is too private for me to take LinkedIn suggestion. The technology for Face ID has been incorporated to I would say 70 percent of the applications at this point, so it tells me that updating your app to use this functionality cannot be that difficult. I'll be waiting for the Face ID recognition in your app soon. I would use your out more by not having to manually put in my password, holding the phone to your face makes my motivation to use your application what greater than having to use my large fingers To type in my password. Thank you, John
|
| 194 |
+
Reanalysis of the original data on DSB repair shows that they are consistent with two first-order repair rates (18 +/- 7 min and about 52 min)
|
| 195 |
+
Please please please for all that is good and holy, please make it so when you connect with someone on the app, it pops up a "personalize invite" modal like it does on the desktop version. Or make the desktop version work like the mobile version (but the desktop flow is better IMO). The amount of times discrepancy between the two platforms has caused me to accidentally send an invite to early & blow a connection because I connect, want to personalize, and then can't, and then need to withdraw and wait 3 (THREE!!) weeks before I can resend is just bonkers. Please please please fix this and I will come back and make this review 5 stars and love you forever. LinkedIn is a great platform, and could be a lot bigger than it is with people spending more time on it. They don't because little stuff like this drives us wild.
|
| 196 |
+
However, 63% of the trisomic fetuses belonged to mothers aged 35 years and above
|
| 197 |
+
This clinical commentary discusses the current management of the syndrome including the Abbe-McIndoe procedure, the most commonly used method of surgical correction, and the Frank vaginal dilation method, the most common nonsurgical method of correction
|
| 198 |
+
Sibpair analysis (n=54) resulted in maximum lod scores (MLSs) of 1.5, 2.6, 3.2, and 2.3 for these markers, with a peak multipoint MLS of 3.5
|
| 199 |
+
Fifty-one per cent of stroke victims were below the age of 54 years
|
| 200 |
+
The main thing I use LinkedIn for is messaging and the past couple of months with these updates the messaging has gone so downhill with more glitches and bugs than ever it makes me not even want to use it. Sometimes I click on a person to message and it just never pulls up the conversation or freezes when I am trying to hit send. Also, when you have a search going, it now disappears the moment you click out of the app to read a text or something which makes you have to keep putting in what you are searching for and it never used to do that. This app used to be great before they ruined it with pointless updates that only made things worse with no noticeable improvements. I would have rated 5 stars before these updates if that tells you how frustrating this has become.
|
| 201 |
+
This study tested the hypothesis that to reduce the rate of macrosomic infants in gestational diabetes cases, good glycemic control should be initiated before 34 completed gestational weeks
|
| 202 |
+
Thirty-five per cent of patients died within 1 week of the stroke
|
| 203 |
+
This is a perfect example of what NOT to do in mobile app design. 1. Useless messenger button that does nothing but try and get you to download the app by popping not one, but two screens (if you don't have it). I won't load Facebook Messenger because it won't let me select no tracking without constantly being harassed. 2. Extremely small "x" for closing annoying ad windows so that if you have any normal or larger than normal finger (god forbid you try and use your thumb) then you end up opening the item instead. 3. Allowing you to block some advertisers for only 30-Days at a time??? Seriously. If it wasn't bad enough that you show me adds from advertisers I've already blocked, now you're unblocking them after 30 Days??4. Extremely small "x" for closing annoying recommended groups windows so that if you have any normal or larger than normal finger (god forbid you try and use your thumb) then you end up opening the item instead. Can I just get you to stop recommending stuff to me? I'm not sure what algorithm you use but the recommendations are horrible. 5. Translations are rarely ever correct. Give up here and just license Google's translator. I'm not saying Google Translate is good, but it would at least be correct more than your translations are. There are more, I'm sure. But we will stop here.
|
| 204 |
+
In the present study, parthenogenotes were obtained by the activation of immature oocytes with caffeine before treatment with 1-methyladenine (1-MeAde) to induce oocyte maturation
|
| 205 |
+
CONCLUSIONS : A complete genomic screen in families affected with late-onset AD identified 4 regions of interest after follow-up
|
| 206 |
+
More of those initially prescribed antibiotics initially returned to the surgery with sore throat (38% v 27%, adjusted hazard ratio for return 1.39%, 95% confidence interval 1.03 to 1.89)
|
| 207 |
+
These were matched for maternal age, parity, and race to twin gestations (N = 53) from the same population
|
| 208 |
+
I am a blind person using the screen reader voiceover for iOS and I am having some serious VoiceOver accessibility problems. For one thing, in the new version, when I double tap on my Contacts, the app crashes every time. Secondly, when I am browsing through my people you may know section, the app get slower and slower and slower the longer eyebrows. Thirdly, if you were using voiceover, there is no option to remove someone from your people you may know list so that you do not see them anymore. The accessibility problems with this app used to be somewhat minimal. I am really looking forward to seeing this app fixed and using LinkedIn again. It is one of my favorite social networking platforms. I apologize if other people are bored by this review, but LinkedIn also does not have any where in the app or on the app support page to provide feedback about the app. I would also be kind of nice if developer of LinkedIn provided details about its updates in its release notes, but many App developers don't do this. It's just a pet peeve.
|
| 209 |
+
Similar B/W differences were observed for patients with advanced disease but not with early stage
|
| 210 |
+
For additional evaluation, females were divided into two subgroups, osteoporotic or nonosteoporotic, based on x-rays and lumbar bone mineral density (BMD) as measured by qCT
|
| 211 |
+
METHODS : The effect of roxithromycin was assessed in a double-blind, randomised, prospective, multicentre, parallel-group, placebo-controlled pilot study of 202 patients with unstable angina or non-Q-wave myocardial infarction
|
| 212 |
+
The advantages and pitfalls of this new method are discussed in the light of the results obtained with an infantile GSDII patient
|
| 213 |
+
It is generally assumed that abiotic synthesis of amino acids on the early Earth resulted in racemic mixtures (L- and D-enantiomers in equal abundance)
|
| 214 |
+
Horrible app/ Customer service
|
| 215 |
+
Evidence for the presence of a serotonin1A (5-HT1A) receptor subtype in the salmonid fish brain has recently been presented
|
| 216 |
+
At 293 sites, we randomly assigned 8803 patients who had had myocardial infarction, treatment with 160 mg aspirin, 3 mg warfarin with 80 mg aspirin, or 1 mg warfarin with 80 mg aspirin
|
| 217 |
+
On admission, patients were randomly assigned G-CSF (filgrastim) therapy (n = 20) or placebo (n = 20) for 7 days
|
| 218 |
+
SETTING : Cullen Eye Institute, Baylor College of Medicine, Houston, Texas, USA
|
| 219 |
+
Women in the cocaine positive group were of higher parity (3, (0-7) vs. 1, (0-6), p = 0.001) and tended to be older (27, (23-42) vs. 25, (14-40))
|
| 220 |
+
Transesophageal echocardiography demonstrated aliasing on color flow mapping in the left main coronary artery in 1 case and proximal right coronary artery in the other, along with severely depressed left ventricular anterior wall and right ventricular function, respectively
|
| 221 |
+
Fluoride increased the vertebral BV/TV by 35% (P & # 60 ; 0.05) and tibial ash weight by 10% (P & # 60 ; 0.05)
|
| 222 |
+
iPhone X
|
| 223 |
+
PARTICIPANTS : Institutions selected 798 persons representing families (1 person for each family) thought to be at elevated a priori risk of BRCA1 mutation due to potential risk factors, such as multiple cases of breast cancer, early age of breast cancer diagnosis, and cases of ovarian cancer
|
| 224 |
+
After 7 days ' treatment, neutrophil superoxide production was significantly higher in the G-CSF group than in the placebo group (16.1 (4.2-24.2) vs 7.3 (2.1-11.5) nmol per 10 (6) neutrophils in 30 min ; p & # 60 ; 0.0001)
|
| 225 |
+
Curtis, Radiat
|
| 226 |
+
The objective of this study was to determine if the rate of preeclampsia is increased in triplet as compared to twin gestations
|
| 227 |
+
This is one super cool, fast paced, never boring but it does make you want shout! Absolutely one of the best games I've played online ever. This game wants you to advance extremely fast, quick and easy. But it also shows you how to manipulate, destroy and leave one of your friends broken, broke and mad because I chose to leave their building's destroyed so they have to fix them. That costs more money, money is a big deal in this game. Everything costs money and breaking into your opponents bank and stealing their life's savings is a bit evil, but I had fun doing it and it was worth the risk. The farther I put him back the further I can get a head.
|
| 228 |
+
This app is really frustrating to use. When looking at a company's open roles, I can't filter by location because it will just reset the search to all jobs in that location, not restricting to the company whose page I was just on. If I try to restrict further by searching the company name, all these unrelated jobs show up in the search. This doesn't happen on desktop so I'm not sure why it's happening on mobile. Also the filters aren't very functional - so many jobs that are included in the "entry level" filter but require 7+ years experience, or are marked as fully remote but are actually fully in person. The jobs I'm recommended aren't even close to the field or function of my current roles. My home feed is just full of fake inspirational posts about CEOs deigning to give their employees breadcrumbs and people foaming at the mouth over their "generosity". It's not a good tool anymore.
|
| 229 |
+
SETTING : Clinic populations in the continental United States
|
| 230 |
+
When each group was divided into two additional subgroups based on the initial PI, it was found that ENS could reduce PI and increase PT significantly (P & # 60 ; 0.05), but did not significantly (P > 0.05) improve ROM, as compared with the placebo group for both subgroups
|
| 231 |
+
Sequelae include severe developmental delay and asymmetric double hemiplegia
|
| 232 |
+
< TO_SEE > METHODS : We identified valvular heart diseasein 24 women treated with fenfluramine-phentermine who had no history of cardiac disease
|
| 233 |
+
Sera were drawn 12 days from ovulation in non-IVF cycles or 9 days after ET and were assayed for PIBF and beta human chorionic gondotropin
|
| 234 |
+
Update: LinkedIn heard my prayers and fixed my app. I no longer get kicked out when trying to send direct messages, and the app works generally faster. Thank you so much for giving me the opportunity to utilize this awesome app to its fullest potential again! Let me start out by saying that LinkedIn has changed my life in so many positive ways. I found my new career through the app, and started down a journey of positive personal development when I rejoined the platform. Hence the four stars. I needed to take one star away because, at least in the app, it almost always crashes and boots me out when I try to send a Direct Message to someone. I end up needing to go in and retype the message 2-3 times before it will send without crashing and closing out. I know plenty of other people are experiencing the same problem, because they post about it. Please fix this issue, I love this platform for so many reasons, but this is frustrating! Thank you in advance for your attention and all that you do to make LinkedIn the wonderful place that it is! Update: my app has worked perfectly since the last update. No more getting booted out when sending DMs. Thank you LinkedIn!
|
| 235 |
+
Until a few days ago I was able to open and converse through my LinkedIn app on my iPhone 7 Plus now all of a sudden it doesn't open up I've deleted it I've installed updates on my iPhone I've done everything it said and every time I download the LinkedIn app it won't open up I don't know what's going on I can access it on a regular computer but for some reason I can't access it through the app on my iPhone I don't know if I'm having the same issue as the person before me that wrote about not being connected to Wi-Fi Not being able to access itbut I was able to even get itWithout Wi-Fi about a week ago I don't understand what's going on
|
| 236 |
+
The most common indication leading to diagnosis was abnormal ultrasound finding (48.2%), followed by advanced maternal age (44.4%)
|
| 237 |
+
The manufacturer claims that moxonidine `` accurately targets imidazoline receptors at the cardiovascular control centre in the brainstem '' and is `` as effective as current first-line therapies for essential hypertension ``
|
| 238 |
+
Need more options
|
| 239 |
+
A radiation risk for all sarcomas combined was evident at doses above 5 Gy, rising to 10.7-fold at doses of 60 Gy or greater (P & # 60 ;.05)
|
| 240 |
+
However, careful visual assessment of the recorded waveforms for the remaining ears did not unequivocally show absence of TEOAE characteristics in any ear with normal HTLs
|
| 241 |
+
The average postoperative metatarsophalangeal dorsiflexion angle was 15.6 degrees and the first metatarsophalangeal angle was 3.1 degrees
|
| 242 |
+
CONCLUSIONS : Double-bolus alteplase was not shown to be equivalent, according to the prespecified criteria, to accelerated infusion with regard to 30-day mortality
|
| 243 |
+
OBJECTIVE : To investigate several loci spanning 3 candidate regions for a role in multiple sclerosis (MS) susceptibility in 2 ethnic groups using both single-locus and haplotype analyses
|
| 244 |
+
The involved upper trapezius muscles were treated with three different methods according to a random assignment : group A muscles (n = 18) were given placebo treatment (control group) ; group B muscles (n = 20) were treated with electrical nerve stimulation (ENS) therapy ; and group C muscles (n = 22) were given electrical muscle stimulation (EMS) therapy
|
| 245 |
+
An LRP was only observed on no-go trials when the semantic (not the phonological) decision determined the response hand
|
| 246 |
+
I love this app however it frustrates me that the current job title seems to be the only factor that is included when recruiters are delivered search results. As a consultant, my title can be anything and it may be quite different from my functional role and area of expertise. Depending upon the currently listed job title, I've noticed a sharp difference in the number contacts I receive from recruiters and the roles they are seeking. I wish that other factors such as education, certifications and skills would be considered when recruiters are delivered search results. Also, LinkedIn should allow subscribers to search by certification or certificate. Currently a search for common certifications returns no results despite the fact that these certifications are listed within job descriptions.
|
| 247 |
+
Eight women also had newly documented pulmonary hypertension
|
| 248 |
+
The daily high temperature ranged from 71 to 104 degrees F and AFI values ranged from 1.7 to 24.7 cm during the study period
|
| 249 |
+
I spent about an hour carefully crafting a reply to a message, had assembled a bunch of links and useful information for the recipient, and had to navigate away from the app momentarily to grab a link. When I switched back to LinkedIn, the app opened to the main landing page and when I reopened the message I had been composing a reply to, the text field was empty. I lost an hour of work and I feel so frustrated, distraught, and disheartened. Auto-save for in-progress messages is pretty basic functionality for most messaging programs and if your app had had it, I wouldn't be in this situation. I wish I could setup a LinkedIn auto-response that directs everyone who messages through LinkedIn me to contact me on a different interface, so that I could avoid using LinkedIn's messaging tool entirely. Objectively, the app is basically fine aside from this issue and does not really deserve only one star. I'm just still reeling from the emotional blow of this experience and looking for an outlet for my frustration as well as to share this feedback with you while it's fresh and in a way that will help your team feel the pain that can be caused by this product gap and really understand the impact. Thanks for reading.
|
| 250 |
+
Synergism in tumorigenesis and independent segregation of the MSI phenotype suggest that Msh2 and p53 are not genetically epistatic
|
| 251 |
+
The rate of macrosomic and large-for-gestational-age infants were 5 and 11%, respectively, in the early group as compared to 25 and 29% in the `` late '' group (p & # 60 ; 0.05)
|
| 252 |
+
Warning about LinkedIn Terrible
|
| 253 |
+
Professional Networking Tool
|
| 254 |
+
The rehabilitation consultation service is particularly effective as an introduction to hospital-based medical practice due to the diagnostic variety of the patients, the functional approach of rehabilitation, and student exposure to multiple hospital settings
|
| 255 |
+
The rate of severe preeclampsia was increased significantly in the triplet group 12 of 53 (22.6%) as compared with the twin group 3 of 53 (5.7%) (OR = 4.9, 95% CI 1.2-23.5, p = 0.02)
|
| 256 |
+
Not a safe place to have a professional opinion in most professions. Especially if you are a women. LinkedIn does not respect women whom have professional scientific reasoning and professional opinions that vary from the political slant posted by either a member or otherwise linkedin; however LinkedIn will continue to push the political slant into your feed continually inviting you to acknowledge the political topic which is slanted and not academically or professionally sound. If you don't like it and if you contribute your intelligent, professional and academically sound comment LinkedIn members state hate things to a person such as Members stating hate statement to me and others I have witnessed ). Very disappointed in the lack of professionalism from LinkedIn employees whom allow the members and themselves to be so hateful to women and others on LinkedIn whom state professional scientific academically sound verbiage on a topic.
|
| 257 |
+
Account was Banned?
|
| 258 |
+
Our data suggest that the relative analgesic potency of epidural S : F is approximately 5 and that there are no differences between the opioids in the onset, duration, and effectiveness of analgesia when equianalgesic doses are administered postoperatively after lidocaine anesthesia for C/S
|
| 259 |
+
When a job expires I can't see it on the app only on the actual website. There's to be a way to archive old jobs that I've applied to. When applying to jobs externally it reroutes you to a different web browser entirely so you don't lose your application. Is there a way to add quick swipe left so when there's a job that you're not interested in you can hide it?It would also be nice to add a filter to where you can check "see only see United States jobs available for anyone in the United States and the state you live in for remote jobs instead of the app showing for example "Chicago, IL United States" and you can apply for these jobs because the job poster only wants candidates from that area state. This would make job searching a lot faster.
|
| 260 |
+
INTERPRETATION : Antichlamydial antibiotics may be useful in therapeutic intervention in addition to standard medication in patients with coronary-artery disease
|
| 261 |
+
1-year life-table estimates for the primary event were 8.6% (95% CI 7.6-9.6) for 160 mg aspirin, 8.4% (7.4-9.4) for 3 mg warfarin with 80 mg aspirin, and 8.8% (7.6-10) for 1 mg warfarin with 80 mg aspirin
|
| 262 |
+
Paid experience is same as free experience
|
| 263 |
+
Twenty-four patients with cerebral palsy and 24 control subjects between the ages of 4 and 12 yr were enrolled in this study
|
| 264 |
+
This study examined the aggressiveness of colon tumors in blacks and whites to explore its role in the racial survival differences
|
| 265 |
+
In the `` early '' and `` late '' groups, mean gestational age at the beginning of treatment was 30.0 +/- 3.8 and 36.2 +/- 1.2 weeks, and duration of treatment was 9.6 +/- 4.1 and 3.7 +/- 1.8 weeks, respectively
|
| 266 |
+
In fact, time course studies demonstrated an even faster onset of apoptosis in ceramide-treated BCR-ABL+ BAF3 cells as compared to normal controls
|
| 267 |
+
after being a paying premium linkedin member for years, my account was suddenly deleted by linkedin simply because i chose to talk openly with others on it about being treated for sickness i've dealt with that was related to the moderna vaccine - and trying to spread helpful information to others who may experience the same issue. i'm also a single parent trying to find work ASAP after the past two years of struggle. linkedin has been deleting actual doctors who have been trying to help with this stuff as well. it's clear the people in linkedin's trust & safety department don't care about any of this and have joined the insane groupthink cult governing too many of these companies now, censoring anyone who might displease their advertisers, even to their userbase's detriment. the fact that linkedin has started using engagement and suppression algorithms like twitter and facebook is making it an increasingly more toxic environment as well. i've switched to indeed. linkedin has become absolutely inhumane and will sink its own ship if this is how it continues to conduct business.
|
| 268 |
+
But the origin of life required, owing to conformational constraints, the almost exclusive selection of either L- or D-enantiomers, and the question of why living systems on the Earth consist of L-enantiomers rather than D-enantiomers is unresolved
|
| 269 |
+
All hand instruments, handpieces and triplex syringes were autoclaved between patients
|
| 270 |
+
The present study also investigates the possible participation of cytoplasmic receptors in the signal transduction pathway, using cotransfection experiments and a new approach that selectively determines the contribution of cytoplasmic receptors in the process of signal transduction
|
| 271 |
+
CONTEXT : A mutation in the BRCA1 gene may confer substantial risk for breast and/or ovarian cancer
|
| 272 |
+
CONCLUSIONS : Families that provide evidence for segregation of an altered HPC1 gene are characterized by multiple cases of prostate cancer that, in most respects, are indistinguishable from nonhereditary cases
|
| 273 |
+
In the past when I was contacted by a recruiter in my message inbox, I could respond directly to the recruiter. Now, the app forces you to respond whether or not you are interested in the job before you have a chance to respond. To me this is a 100% unnecessary feature and is an unprofessional way to contact a recruiter to express interest. The right way to treat a recruiter is to write that person directly and let them know your intentions, perhaps including some background for your decision. This can help keep communication channels open in the future and is a networking opportunity. This new feature is a result of someone "overthinking" the situation. This goes back to the old saying, "If its not broke, don't fix it"
|
| 274 |
+
The human DNA mismatch repair (MMR) genes are highly conserved homologues of the Escherichia coli MutHLS system, which contribute to genomic stability by surveillance and repair of replication misincorporation errors and exogenous DNA damage
|
| 275 |
+
Overall, the age-specific rates for both sexes rose with age, with the rates for women being higher at all age strata except for the group 45-54 years
|
| 276 |
+
This partial recovery is also parallel to the partial activation of JAK2, indicating that the signal transduction pathway implicated JAK2
|
| 277 |
+
The objective of this article is to explore attitudes of an inner-city, pregnant cohort about general and HIV-related prenatal care
|
| 278 |
+
Horrible
|
| 279 |
+
CONTEXT : Approximately 9% of prostate cancer cases have been estimated to result from inheritance of mutated prostate cancer susceptibility genes
|
| 280 |
+
I had a LinkedIn account that I ran my photography business through messaging & sharing with customers sharing links they would send me and me to them. After at least five years of using the App LinkedIn stopped letting me log into my account asking for verification on KYC which I provided after about two months and the account still not able to login. I inquiring as to why the account was shutdown and not letting me login. LinkedIn finally has sent me an email saying that I have shared post or links they don't want being forwarded. No warnings in five years of being loyal to the brand and they kicked me to the curve like I an ugly dog to them. It really hurt my business and feelings that this is as good as it gets in business today use at your own risk don't put nothing on the platform you want to keep because I was locked out. I shared a few Crypto links?
|
| 281 |
+
The absolute worst networking app...
|
| 282 |
+
There was no significant difference of mean latency, amplitude, or amplitude ratio of SSR between the two groups under electric stimulus, startling stimulus, and deep breathing conditions
|
| 283 |
+
Ropinirole-treated patients had a significantly greater percentage improvement in UPDRS motor score than patients who received placebo (+24% vs -3% ; p & # 60 ; 0.001)
|
| 284 |
+
RESULTS : In 102 women (12.8%), clearly deleterious mutations were detected
|
| 285 |
+
It is not known if such damage occurs in humans or if there are clinical consequences
|
| 286 |
+
App is still terrible, customer service is worse
|
| 287 |
+
Furthermore, when all deliveries were analyzed, regardless of risk status but limited to gestational age > or = 36 weeks, the rates did not change (12.6%, 280 of 2214 ; primary 9.2%, 183 of 1994)
|
| 288 |
+
Seven basic considerations can be identified that determine the evidence contained in a signal : quantitative strength of the association, consistency of the data, exposure response relationship, biological plausibility, experimental findings, possible analogies and the nature and quality of the data
|
| 289 |
+
As the ambient temperature increases, there is an increase in insensible fluid loss and the potential for dehydration
|
| 290 |
+
The heart valves had a glistening white appearance
|
| 291 |
+
Side effects were recorded
|
| 292 |
+
The mean training time per week was 3.2 +/- 0.8 h. In the entire group of female dancers, no significant effects of dancing on radial or lumbar bone density could be observed
|
| 293 |
+
In the age of TikTok and Instgram, still bans and kicks you off for no reason, but spam accounts selling test prep are fine. Apparently if you are looking for jobs and networking and you're using the app they will "ban" or "restrict" your account with no explanation except for "we've detected automated activity" I'm using the linkedin app on my iPhone! Why the developers don't implement some features to deter actual automated activity such as: -Have users take a selfie, like Uber Driver app does -Remind users they have been scrolling for too long, like tiktok does -Tell users what the "threshold" for use is instead of having a "blackbox" and then issuing a ban. This is probably the worse customer experience/ user experience in an app ever! Should we use your app or not? Do we use your app and then get banned for no reason!? Linkedin Dev team please address this!!
|
| 294 |
+
METHODS : 40 diabetic patients with foot infections were enrolled in a double-blind placebo-controlled study
|
| 295 |
+
Antibiotics prescribed for sore throat during the previous year had an additional effect (hazard ratio 1.69, 1.20 to 2.37)
|
| 296 |
+
The Biopsychosocial Model of medical practice is demonstrated through multiple interdisciplinary perspectives of needs and interventions for patients with obvious functional deficits
|
| 297 |
+
Great app. Unsubscribe from emails before Earth Dies.
|
| 298 |
+
Nutritional assessment, substrate immunonutrition, and disease specific issues are presented
|
| 299 |
+
Better as a creator
|
| 300 |
+
The respective rates of any stroke and of hemorrhagic stroke were 1.92 and 1.12 percent after double-bolus alteplase, as compared with 1.53 and 0.81 percent after an accelerated infusion of alteplase (P=0.24 and P=0.23, respectively)
|
| 301 |
+
I have tried on my web browser on my laptop and in the LinkedIn App, and the system will not allow me to save a profile picture. It will let me upload a photo, edit it with cropping and filters, and hit the "save" button, but the photo does not save. In the web browser, the "save" grays out after I press it and does nothing else. If I then close the edit window, it warns that my changes have not been saved and gives only the option to discard changes and close the window or to cancel and return to the editing screen. In the App, if I press save, it comes up with a "saving" message and the wheel spins for about 3 seconds, then the entire app crashes and shuts down. Pretty worthless app if it doesn't even function well enough to put in a profile picture. My other limited experiences with the app have also been negative. When trying to edit education history and work history, the app is clunky and sometimes slow to respond. It is also hard to predict where in your profile the info you are entering is going to actually show.
|
| 302 |
+
They are the most thorough platform available, full of many different types of new beginnings. However, I have not had quality customer support experiences with them. I have been trying to utilize a benefit that they offer veterans for some time now, and something is not working, but when I reach out to them for assistance, I get very little other than a vague response. I have contacted them on several occasions, but it's still a mystery to me why this opportunity continues to elude me. I have given up hope, and I rarely use (or try to) the site anymore because of this. It makes me sad because I don't have the resources and abilities I once possessed, and this is the best place for me to focus on what I CAN do and learn about new opportunities. I was looking forward to this being the beginning of the most lucrative and rewarding career in my lifetime. Yet, I have hit another stumbling block instead.
|
| 303 |
+
Extremely buggy on iOS on iPhone 12 max. I've deleted the app and reinstalled. There's some very badly managed API calls that feel like it's using jQuery instead of a modern MVC framework causing every other scroll to click instead. I don't understand why it became so much worse, or how QA engineers let this out the door. It's very badly managed developers and software. This was not an issue at all a couple years ago, shortly after Microsoft bought LinkedIn. It is however unsurprising that the UI became much worse after MS got involved and MS has no design cycle nor do they have a QA cycle so it's absolutely likely the UI will continue to get worse. Sigh, hopefully I'll land a job so I don't need to use this broken and frustrating UI much longer. The most frustrating part it I tried to tell LinkedIn and the customer support claimed the couldn't replicate, which is funny because I tested on several iPhones. It's gaslighting on their part. The whole customer support experience also reminded e of Microsoft, where thy just don't believe you and assume the customer is doing something wrong. I don't think they want to fix it.
|
| 304 |
+
CONCLUSIONS : The significant chromosome 19q13.2 single-locus and multilocus haplotype associations with MS in Caucasian and Chinese patient samples indicate an effect from a nearby disease susceptibility locus
|
| 305 |
+
LinkedIn is the absolute worst, corrupt, and non user friendly social app out there. There are too many reasons to even begin mentioning all of them. Just to name one: if a user wants to unfriend or leave a group, LinkedIn intentionally makes it impossible to do so on the mobile app.I'm also still dealing with a "glitch" that the app had a few years ago. Without giving any permission to access my iPhone's contacts, LinkedIn automatically dumped every one of my LinkedIn contacts into my iPhone's contacts. Well over 500 contacts were combined with my personal contacts. Not a big deal you say? Well those contacts birthdays were then automatically also added to iPhone's calendar. Everyday I would have at least a couple of these birthdays showing. For well over a year I was manually removing these contacts one name at a time. LinkedIn admitted to the error. Thanks that did nothing to help. So at this point I have zero trust in the integrity, honesty, or reliability of LinkedIn.
|
| 306 |
+
Most adverse experiences were related to peripheral dopaminergic activity
|
| 307 |
+
I like the app. I recently started trying to use the job alert feature. I searched for a job ("software engineer") at a mid-sized clothing retailer. I have seen open roles there before, and I know they employ software developers, but they don't happen to have any open roles right now. I would like to create an alert for when such a role does open up, but the "turn on alerts for this search" toggle only appears for searches that currently produce results. Please add the toggle to all job searches, no matter how specific, since this is the point of these alerts-to let users know if/when a relevant job pops up. Also there's no obvious "feedback" area in the app, so I had to come here. :(
|
| 308 |
+
The initial hemoglobin was 2.2 g/dL, the Kleihauer-Betke stain was 27.6% (highest level ever reported)
|
| 309 |
+
Prevalence on the basis of enzyme immunoassay was 1.6% (0.8% to 2.7%), with a sensitivity of 60% and a specificity of 100%
|
| 310 |
+
I hired Zalak to design my site based on two criteria: 1 She had a very fine portfolio. 2. More importantly, she asked me for my business, then asked again, then asked again. I said yes and for the next year, was never sorry for a single moment. First, she is a highly skilled and wonderful designer with an eye for art that is hard to find. 2. She has common sense and approaches her design as a customer centered business understanding that in the end, I needed to be satisfied and happy with her product, for her to be happy with her job; 3. I can absolutely trust her with my site, including passwords, and project funding (she was not only a designer but a great project manager). 4. She set me up with her own programmers in which I paid her and she paid them. She guaranteed them and she got what she wanted from the without damaging the site. (Something I had a hard time doing). I found Zalak to be pleasant and personable. She was always on time and always dependable and always did what she told me she would do. I have hired dozens of talented off-shore programmers/designers and few do I give a recommendation and none do I give a full throated recommendation to, except to Zalak. Call me any time in Missouri to talk to me.
|
| 311 |
+
It could reduce PI significantly more (P & # 60 ; 0.05) than placebo controls only for the subgroup with mild to moderate pain, but not with severe pain
|
| 312 |
+
RESULTS : PIBF was detected in 29.5% of non-pregnant women and 52.5% of pregnant women
|
| 313 |
+
API calls make UI stall
|
| 314 |
+
Transiently evolked otoacoustic emissions (TEOAE) have been reported in several studies as absent in a small minority of normal ears
|
| 315 |
+
I too am very disappointed with LinkedIn and the direction it has taken. This previously was a great site for people to communicate with other people and companies. Whether it was trying to get a job, improving their position, finding a new workplace due to moving to a new area or trying to move up to a better job, talking with others about how to improve their job skills so they can prepare for a better job or keeping in touch with friends they worked with in the past or, even better, finding old friends and learning how to stay in touch with them. It WAS a great site. Now, as the previous commenter said, LinkedIn is now just another Facebook or Twitter. It's totally lost all the professionalism it started with. If anyone in power within the LinkedIn organization reads this comment, please, please, PLEASE change the format back to what the original people or person had as their vision. If you don't, I can foresee this site going down the tubes. I, for one, will do my best to start a new site with only one goal... to make a website for companies and individuals to get together and help each other grow both as a company and as individuals who want to help a company grow and a company willing to help their dedicated individuals grow within the company. Also, have learning institutions small and large contribute to the individuals growth.Is anyone out there with me on this?
|
| 316 |
+
I've heard so many people speak highly of LinkedIn, so I tried it out. Unfortunately this app does NOT work, as it needs serious updates. When you try to apply for any of the job listing your are given the option to give permission to attach your LinkedIn profile in replace or your resume, it does NOT work. Once you click you get a white screen or when it does go through to the next screen where it will ask for your name and contact info it freezes. Therefore you can not apply for any of their job listings. So I look up the company on google to find their website and then apply that way with my own resume. The only thing LinkedIn is good for right now is to locate a job listing. Then apply on their site. I gave 1 star because at least they still proved job listing I can apply to on the companies web site directly.
|
| 317 |
+
This article discusses many of the nutritional topics important to the intensivist
|
| 318 |
+
The effect was most pronounced in the central part of the treated area, where no positive fox was found in the 2 months before the end of the trial
|
| 319 |
+
It offers potential as a non-invasive screening tool
|
| 320 |
+
The function of the short form was examined after cotransfection of both the long and short forms
|
| 321 |
+
I honestly have never written a review before, but I left LinkedIn because of this when I was fond of building connections on LinkedIn. However, someone had to make me very uncomfortable. LinkedIn is for professional use. When posting, there should be a sensitivity filter so what ever comment or post is being shared on LinkedIn directly is not hurtful or disrespectful. I had a horrible experience with someone disrespecting my profession and the person said "in ms your considered a joke" on my post that involved my profession having an appreciation week. That is the total opposite of what LinkedIn in is used for.Update: it's sad to see that my review was overlooked and not cared about. Bullying or any form of making someone uncomfortable especially on a professional job app should be taken seriously. You should be able to post about your profession without someone trying to undermine a position. What a shame.Adding on to my review: I hope LinkedIn is able to talk with him, and give him a warning. It's sad because I did not want to leave LinkedIn, but because of this behavior that should not be tolerated I left and it was probably for the best. I just wanted to be recognized for all the hard work I do for my job and post it on my page and someone had to ruin it all. I know it's not you LinkedIn, but I hope you can help me.
|
| 322 |
+
Trisomy 18 fetuses showed hydramnios, intrauterine growth retardation, microcephaly, spina bifida, and nonimmune hydrops fetalis
|
| 323 |
+
Serious VoiceOver accessibility problems
|
| 324 |
+
PARTICIPANTS : Two hundred and seventy-three 'first-ever ' strokes prospectively identified over a 12-month period
|
| 325 |
+
Most of the content I've encountered on this app is fake. Fake people with their fake jobs and fake integrity awards. No. These are the lowest most untrustworthy people on the planet. Sorry but I can't play your charades. The job section is a hot mess. More fake advertisements than Indeed and that's hard to beat. Then you have a bunch of fake made of companies with their websites that took them under and hour to make. Are people still being fooled? Most of the jobs that are real are expired and outdated or closed. It just a stalker app. Provides no real business connections or opportunities.if you try to delete the profile they still want to advertise you for up to 2 more weeks. Sorry but when I delete you no longer have the option to do anything with my personal information. This app is useless. Just people spying on me or sending odd spam email. I can't wait until I figure out how to reclaim my information from these jerks!
|
docparse/data/corpus_train.txt
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
docparse/extract.py
ADDED
|
@@ -0,0 +1,721 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""One entry point for every format: extract(path) -> document dict.
|
| 2 |
+
|
| 3 |
+
Document shape (JSON-serialisable):
|
| 4 |
+
{"source", "format", "metadata", "pages": int, "blocks": [...], "sheets": [...], "warnings": [...]}
|
| 5 |
+
Blocks, in reading order, are one of
|
| 6 |
+
heading {text, level}
|
| 7 |
+
paragraph {text}
|
| 8 |
+
list_item {text, level?}
|
| 9 |
+
table {rows: [[cell, ...], ...]}
|
| 10 |
+
figure {} (pictures; their text is read only with ocr_images=True)
|
| 11 |
+
header / footer {text} (Word page headers and footers)
|
| 12 |
+
Every block can carry page, bbox [x0, y0, x1, y1] (PDF points or image pixels), sheet, source
|
| 13 |
+
("text-layer", "ocr", "structure") and, for OCR output, confidence (mean per-character probability).
|
| 14 |
+
"""
|
| 15 |
+
import csv
|
| 16 |
+
import datetime as dt
|
| 17 |
+
import html
|
| 18 |
+
import io
|
| 19 |
+
import re
|
| 20 |
+
import shutil
|
| 21 |
+
import subprocess
|
| 22 |
+
import tempfile
|
| 23 |
+
from functools import lru_cache
|
| 24 |
+
from pathlib import Path
|
| 25 |
+
|
| 26 |
+
import numpy as np
|
| 27 |
+
|
| 28 |
+
ROOT = Path(__file__).resolve().parents[1]
|
| 29 |
+
DEFAULT_MODEL = ROOT / "artifacts" / "docparse" / "ocr.pt"
|
| 30 |
+
|
| 31 |
+
IMAGE_EXT = {".png", ".jpg", ".jpeg", ".tif", ".tiff", ".bmp", ".webp", ".gif"}
|
| 32 |
+
CONVERT = {".doc": "docx", ".odt": "docx", ".rtf": "docx", ".xls": "xlsx", ".ods": "xlsx",
|
| 33 |
+
".ppt": "pdf", ".pptx": "pdf", ".odp": "pdf"}
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
@lru_cache(maxsize=2)
|
| 37 |
+
def recognizer(path=None):
|
| 38 |
+
from .ocr.model import Recognizer
|
| 39 |
+
path = Path(path or DEFAULT_MODEL)
|
| 40 |
+
if not path.exists():
|
| 41 |
+
raise FileNotFoundError(f"OCR weights not found at {path}. Train them with `docparse train-ocr`.")
|
| 42 |
+
return Recognizer(str(path))
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def extract(path, ocr="auto", ocr_images=False, model=None, dpi=200, password=None):
|
| 46 |
+
"""ocr: "auto" (only pages without a text layer), "always", or "never"."""
|
| 47 |
+
path = Path(path)
|
| 48 |
+
ext = path.suffix.lower()
|
| 49 |
+
doc = {"source": path.name, "format": ext.lstrip("."), "metadata": {}, "pages": 0, "blocks": [],
|
| 50 |
+
"sheets": [], "warnings": []}
|
| 51 |
+
opts = {"ocr": ocr, "ocr_images": ocr_images, "model": model, "dpi": dpi, "password": password}
|
| 52 |
+
if ext in CONVERT:
|
| 53 |
+
with tempfile.TemporaryDirectory() as tmp:
|
| 54 |
+
converted = _libreoffice(path, CONVERT[ext], tmp)
|
| 55 |
+
doc["warnings"].append(f"converted {ext} to .{CONVERT[ext]} with LibreOffice before parsing")
|
| 56 |
+
_dispatch(converted, converted.suffix.lower(), doc, opts)
|
| 57 |
+
else:
|
| 58 |
+
_dispatch(path, ext, doc, opts)
|
| 59 |
+
return doc
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def _dispatch(path, ext, doc, opts):
|
| 63 |
+
if ext == ".pdf":
|
| 64 |
+
pdf(path, doc, **opts)
|
| 65 |
+
elif ext in IMAGE_EXT:
|
| 66 |
+
image(path, doc, opts["model"])
|
| 67 |
+
elif ext in (".docx", ".docm", ".dotx"):
|
| 68 |
+
docx(path, doc, opts["ocr_images"], opts["model"])
|
| 69 |
+
elif ext in (".xlsx", ".xlsm", ".xltx"):
|
| 70 |
+
xlsx(path, doc, opts["ocr_images"], opts["model"])
|
| 71 |
+
elif ext in (".csv", ".tsv"):
|
| 72 |
+
delimited(path, doc)
|
| 73 |
+
elif ext in (".txt", ".md"):
|
| 74 |
+
text_file(path, doc)
|
| 75 |
+
else:
|
| 76 |
+
raise ValueError(f"unsupported file type: {ext}")
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def _libreoffice(path, target, out_dir):
|
| 80 |
+
exe = shutil.which("soffice") or shutil.which("libreoffice")
|
| 81 |
+
if not exe:
|
| 82 |
+
raise RuntimeError(f"{path.suffix} files need LibreOffice (soffice) for conversion")
|
| 83 |
+
subprocess.run([exe, "--headless", "--convert-to", target, "--outdir", out_dir, str(path)],
|
| 84 |
+
check=True, capture_output=True, timeout=180)
|
| 85 |
+
out = Path(out_dir) / (path.stem + "." + target)
|
| 86 |
+
if not out.exists():
|
| 87 |
+
raise RuntimeError(f"LibreOffice could not convert {path.name}")
|
| 88 |
+
return out
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
# ---------- images ----------
|
| 92 |
+
|
| 93 |
+
def image(path, doc, model=None):
|
| 94 |
+
from PIL import Image, ImageSequence
|
| 95 |
+
from .ocr.page import analyze
|
| 96 |
+
rec = recognizer(model)
|
| 97 |
+
img = Image.open(path)
|
| 98 |
+
frames = [f.copy() for f in ImageSequence.Iterator(img)] if getattr(img, "n_frames", 1) > 1 else [img]
|
| 99 |
+
doc["metadata"].update({"width": img.width, "height": img.height, "mode": img.mode})
|
| 100 |
+
for n, frame in enumerate(frames, 1):
|
| 101 |
+
doc.setdefault("page_sizes", []).append([frame.width, frame.height])
|
| 102 |
+
blocks, meta = analyze(frame, rec)
|
| 103 |
+
mark_page_furniture(blocks, frame.height)
|
| 104 |
+
for b in blocks:
|
| 105 |
+
b.update(page=n, source="ocr")
|
| 106 |
+
doc["blocks"] += blocks
|
| 107 |
+
doc["metadata"].setdefault("ocr", []).append({"page": n, **meta})
|
| 108 |
+
doc["pages"] = len(frames)
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
# ---------- PDF ----------
|
| 112 |
+
|
| 113 |
+
def pdf(path, doc, ocr="auto", ocr_images=False, model=None, dpi=200, password=None):
|
| 114 |
+
import pdfplumber
|
| 115 |
+
with pdfplumber.open(path, password=password) as pdf_:
|
| 116 |
+
doc["metadata"].update({k: str(v) for k, v in (pdf_.metadata or {}).items() if isinstance(v, (str, int, float))})
|
| 117 |
+
doc["pages"] = len(pdf_.pages)
|
| 118 |
+
for n, page in enumerate(pdf_.pages, 1):
|
| 119 |
+
doc.setdefault("page_sizes", []).append([float(page.width), float(page.height)])
|
| 120 |
+
chars = page.chars
|
| 121 |
+
scanned = len("".join(c["text"] for c in chars).strip()) < 20
|
| 122 |
+
if ocr == "always" or (ocr == "auto" and scanned):
|
| 123 |
+
if ocr == "never":
|
| 124 |
+
continue
|
| 125 |
+
blocks = _ocr_pdf_page(path, n - 1, page, model, dpi, password)
|
| 126 |
+
else:
|
| 127 |
+
blocks = _pdf_text_page(page)
|
| 128 |
+
if ocr_images:
|
| 129 |
+
_ocr_pdf_figures(path, n - 1, blocks, model, dpi, password)
|
| 130 |
+
if scanned:
|
| 131 |
+
doc["warnings"].append(f"page {n} has no text layer; run with ocr='auto' to read it")
|
| 132 |
+
mark_page_furniture(blocks, float(page.height))
|
| 133 |
+
for b in blocks:
|
| 134 |
+
b["page"] = n
|
| 135 |
+
doc["blocks"] += blocks
|
| 136 |
+
try:
|
| 137 |
+
links = [h.get("uri") for h in page.hyperlinks if h.get("uri")]
|
| 138 |
+
except (UnicodeDecodeError, ValueError, KeyError, TypeError): # malformed annotations: skip links
|
| 139 |
+
links = []
|
| 140 |
+
doc["warnings"].append(f"page {n}: hyperlink annotations could not be decoded")
|
| 141 |
+
if links:
|
| 142 |
+
doc["metadata"].setdefault("links", []).extend({"page": n, "uri": u} for u in links)
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
def _pdf_text_page(page):
|
| 146 |
+
from .ocr.page import group, text_blocks
|
| 147 |
+
S = 2.0 # mask pixels per PDF point
|
| 148 |
+
W, H = int(page.width * S) + 2, int(page.height * S) + 2
|
| 149 |
+
tables, candidates = [], []
|
| 150 |
+
for t in page.find_tables():
|
| 151 |
+
candidates.append(t.bbox)
|
| 152 |
+
rows = [[_clean_cell(c) for c in row] for row in _split_stacked_rows(t.extract())]
|
| 153 |
+
cells = [c for row in rows for c in row]
|
| 154 |
+
# Boxes drawn around code or text frames also look like grids; real tables are mostly filled.
|
| 155 |
+
if len(rows) >= 2 and max(len(r) for r in rows) >= 2 and sum(1 for c in cells if c) >= 0.5 * len(cells):
|
| 156 |
+
tables.append((t.bbox, rows))
|
| 157 |
+
tboxes = [b for b, _ in tables]
|
| 158 |
+
|
| 159 |
+
def in_table(w):
|
| 160 |
+
cx, cy = (w["x0"] + w["x1"]) / 2, (w["top"] + w["bottom"]) / 2
|
| 161 |
+
return any(x0 - 1 <= cx <= x1 + 1 and y0 - 1 <= cy <= y1 + 1 for x0, y0, x1, y1 in tboxes)
|
| 162 |
+
all_words = [w for w in page.extract_words(extra_attrs=["size", "fontname", "upright"], use_text_flow=False,
|
| 163 |
+
keep_blank_chars=False) if not in_table(w) and w["text"].strip()]
|
| 164 |
+
# Rotated text (margin stamps, vertical axis labels) would line up with every body line and look
|
| 165 |
+
# like a table column; it is kept out of the layout pass and reported as its own block.
|
| 166 |
+
figures = _vector_figures(page, candidates, all_words)
|
| 167 |
+
figures = [f for f in figures if not any(g != f and _inside(f, g) for g in figures)]
|
| 168 |
+
if any(_inside(b, f) for b in tboxes for f in figures): # a "table" drawn inside a chart is part of it
|
| 169 |
+
tables = [(b, r) for b, r in tables if not any(_inside(b, f) for f in figures)]
|
| 170 |
+
tboxes = [b for b, _ in tables]
|
| 171 |
+
all_words = [w for w in page.extract_words(extra_attrs=["size", "fontname", "upright"], use_text_flow=False,
|
| 172 |
+
keep_blank_chars=False) if not in_table(w) and w["text"].strip()]
|
| 173 |
+
|
| 174 |
+
def in_figure(w):
|
| 175 |
+
cx, cy = (w["x0"] + w["x1"]) / 2, (w["top"] + w["bottom"]) / 2
|
| 176 |
+
return next((k for k, (x0, y0, x1, y1) in enumerate(figures) if x0 <= cx <= x1 and y0 <= cy <= y1), None)
|
| 177 |
+
fig_words = [[] for _ in figures]
|
| 178 |
+
for w in all_words:
|
| 179 |
+
k = in_figure(w)
|
| 180 |
+
if k is not None:
|
| 181 |
+
fig_words[k].append(w)
|
| 182 |
+
all_words = [w for w in all_words if in_figure(w) is None]
|
| 183 |
+
words = [w for w in all_words if w["upright"]]
|
| 184 |
+
rotated = [w for w in all_words if not w["upright"]]
|
| 185 |
+
mask = np.zeros((H, W), bool)
|
| 186 |
+
boxes = []
|
| 187 |
+
for w in words:
|
| 188 |
+
h = w["bottom"] - w["top"]
|
| 189 |
+
y0, y1 = int((w["top"] + 0.18 * h) * S), int((w["bottom"] - 0.18 * h) * S) + 1
|
| 190 |
+
x0, x1 = int(w["x0"] * S), int(w["x1"] * S) + 1
|
| 191 |
+
y0, y1 = max(0, min(y0, H - 1)), max(1, min(y1, H))
|
| 192 |
+
x0, x1 = max(0, min(x0, W - 1)), max(1, min(x1, W))
|
| 193 |
+
mask[y0:y1, x0:x1] = True
|
| 194 |
+
boxes.append((y0, y1, x0, x1, w))
|
| 195 |
+
sizes = [w["size"] for w in words]
|
| 196 |
+
body = float(np.median(sizes)) if sizes else 10.0
|
| 197 |
+
|
| 198 |
+
def read(segs):
|
| 199 |
+
out = []
|
| 200 |
+
for y0, y1, x0, x1 in segs:
|
| 201 |
+
ws = [b[4] for b in boxes if y0 <= (b[0] + b[1]) / 2 <= y1 and x0 <= (b[2] + b[3]) / 2 <= x1]
|
| 202 |
+
out.append((_styled_text(ws, body), None))
|
| 203 |
+
return out
|
| 204 |
+
th = body * 0.64 * S # height of a shrunken word box at body size
|
| 205 |
+
blocks, th = text_blocks(mask, th, read, figures=False)
|
| 206 |
+
for (x0, y0, x1, y1), rows in tables:
|
| 207 |
+
blocks.append({"kind": "table", "box": (int(y0 * S), int(y1 * S), int(x0 * S), int(x1 * S)), "rows": rows,
|
| 208 |
+
"confidence": None})
|
| 209 |
+
for im in page.images:
|
| 210 |
+
x0, y0, x1, y1 = im["x0"], im["top"], im["x1"], im["bottom"]
|
| 211 |
+
if (x1 - x0 >= 10 and y1 - y0 >= 10 and (x1 - x0) * (y1 - y0) > 400
|
| 212 |
+
and not any(_inside((x0, y0, x1, y1), f) for f in figures)):
|
| 213 |
+
blocks.append({"kind": "figure", "box": (int(y0 * S), int(y1 * S), int(x0 * S), int(x1 * S))})
|
| 214 |
+
for (x0, y0, x1, y1), ws in zip(figures, fig_words):
|
| 215 |
+
blocks.append({"kind": "figure", "box": (int(y0 * S), int(y1 * S), int(x0 * S), int(x1 * S)),
|
| 216 |
+
"text": " ".join(w["text"] for w in sorted(ws, key=lambda w: (round(w["top"]), w["x0"])))})
|
| 217 |
+
out = group(blocks, th)
|
| 218 |
+
if rotated:
|
| 219 |
+
x0, y0 = min(w["x0"] for w in rotated), min(w["top"] for w in rotated)
|
| 220 |
+
x1, y1 = max(w["x1"] for w in rotated), max(w["bottom"] for w in rotated)
|
| 221 |
+
text = " ".join(w["text"] for w in sorted(rotated, key=lambda w: (w["x0"], -w["bottom"])))
|
| 222 |
+
out.append({"type": "paragraph", "text": text, "rotated": True,
|
| 223 |
+
"box": (int(y0 * S), int(y1 * S), int(x0 * S), int(x1 * S))})
|
| 224 |
+
for b in out:
|
| 225 |
+
y0, y1, x0, x1 = b.pop("box")
|
| 226 |
+
b["bbox"] = [round(x0 / S, 1), round(y0 / S, 1), round(x1 / S, 1), round(y1 / S, 1)]
|
| 227 |
+
b["source"] = "text-layer"
|
| 228 |
+
b.pop("confidence", None)
|
| 229 |
+
_pdf_heading_by_font(b, words, body)
|
| 230 |
+
return out
|
| 231 |
+
|
| 232 |
+
|
| 233 |
+
def _inside(a, b, tol=2.0):
|
| 234 |
+
return a[0] >= b[0] - tol and a[1] >= b[1] - tol and a[2] <= b[2] + tol and a[3] <= b[3] + tol
|
| 235 |
+
|
| 236 |
+
|
| 237 |
+
def _overlap(a, b):
|
| 238 |
+
w = min(a[2], b[2]) - max(a[0], b[0])
|
| 239 |
+
h = min(a[3], b[3]) - max(a[1], b[1])
|
| 240 |
+
return w * h if w > 0 and h > 0 else 0.0
|
| 241 |
+
|
| 242 |
+
|
| 243 |
+
def _vector_figures(page, tables, words):
|
| 244 |
+
"""Charts and diagrams drawn with vector graphics (not embedded images).
|
| 245 |
+
|
| 246 |
+
Drawing primitives are rasterised onto a coarse grid, dilated and grouped into connected
|
| 247 |
+
regions. A region is a figure when it is large, holds enough primitives and little text
|
| 248 |
+
(shaded tables and boxed text are dense with words; charts and diagrams are not)."""
|
| 249 |
+
from .ocr.page import components
|
| 250 |
+
W, H = float(page.width), float(page.height)
|
| 251 |
+
S = 0.5 # grid cells per point
|
| 252 |
+
prims = []
|
| 253 |
+
for o in page.curves + page.lines + page.rects:
|
| 254 |
+
x0, y0, x1, y1 = o["x0"], o["top"], o["x1"], o["bottom"]
|
| 255 |
+
if (x1 - x0) * (y1 - y0) > 0.5 * W * H or any(_inside((x0, y0, x1, y1), t, 6) for t in tables):
|
| 256 |
+
continue # page backgrounds and table rules
|
| 257 |
+
prims.append((x0, y0, x1, y1, o["object_type"] == "curve"))
|
| 258 |
+
if len(prims) < 4:
|
| 259 |
+
return []
|
| 260 |
+
grid = np.zeros((int(H * S) + 2, int(W * S) + 2), bool)
|
| 261 |
+
pad = 6
|
| 262 |
+
for x0, y0, x1, y1, _ in prims:
|
| 263 |
+
grid[max(0, int((y0 - pad) * S)):int((y1 + pad) * S) + 1, max(0, int((x0 - pad) * S)):int((x1 + pad) * S) + 1] = True
|
| 264 |
+
rows, starts, ends, groups = components(grid)
|
| 265 |
+
out = []
|
| 266 |
+
for g in groups:
|
| 267 |
+
x0, x1 = starts[g].min() / S + pad, ends[g].max() / S - pad
|
| 268 |
+
y0, y1 = rows[g].min() / S + pad, (rows[g].max() + 1) / S - pad
|
| 269 |
+
if x1 - x0 < 50 or y1 - y0 < 40 or (x1 - x0) * (y1 - y0) < 0.01 * W * H:
|
| 270 |
+
continue
|
| 271 |
+
inside = [p for p in prims if _inside(p[:4], (x0, y0, x1, y1), 4)]
|
| 272 |
+
curves = sum(p[4] for p in inside)
|
| 273 |
+
if len(inside) < 6 and curves < 2:
|
| 274 |
+
continue
|
| 275 |
+
text_area = sum((w["x1"] - w["x0"]) * (w["bottom"] - w["top"]) for w in words
|
| 276 |
+
if _inside((w["x0"], w["top"], w["x1"], w["bottom"]), (x0, y0, x1, y1)))
|
| 277 |
+
if text_area > 0.2 * (x1 - x0) * (y1 - y0):
|
| 278 |
+
continue
|
| 279 |
+
area = (x1 - x0) * (y1 - y0)
|
| 280 |
+
if any(_overlap((x0, y0, x1, y1), t) > 0.3 * area for t in tables):
|
| 281 |
+
continue # the rules of a table (pdfplumber's box can be a little smaller than the grid)
|
| 282 |
+
out.append((x0, y0, x1, y1))
|
| 283 |
+
return out
|
| 284 |
+
|
| 285 |
+
|
| 286 |
+
def _font_style(w):
|
| 287 |
+
name = w["fontname"].lower().split("+")[-1]
|
| 288 |
+
bold = any(k in name for k in ("bold", "black", "heavy", "semibold", ",b")) or name.endswith("-b")
|
| 289 |
+
italic = any(k in name for k in ("italic", "oblique", ",i")) or name.endswith("-i")
|
| 290 |
+
return bold, italic
|
| 291 |
+
|
| 292 |
+
|
| 293 |
+
def _styled_text(ws, body):
|
| 294 |
+
"""Words of one text segment -> text with Markdown emphasis and <sup>/<sub> from the PDF's fonts.
|
| 295 |
+
|
| 296 |
+
Characters in a different size come back as separate words ("x" "2"); they are joined without
|
| 297 |
+
a space when they touch, and marked as superscript or subscript from their baseline."""
|
| 298 |
+
if not ws:
|
| 299 |
+
return ""
|
| 300 |
+
lines = {}
|
| 301 |
+
for w in sorted(ws, key=lambda w: w["top"]):
|
| 302 |
+
key = next((k for k in lines if abs(k - w["bottom"]) < 0.6 * max(w["size"], body)
|
| 303 |
+
or (w["top"] >= k - 0.9 * body and w["bottom"] <= k + 0.4 * body)), None)
|
| 304 |
+
lines.setdefault(w["bottom"] if key is None else key, []).append(w)
|
| 305 |
+
pieces = [] # (text, bold, italic, joined_to_previous)
|
| 306 |
+
for key in sorted(lines):
|
| 307 |
+
line = sorted(lines[key], key=lambda w: w["x0"])
|
| 308 |
+
big = max(w["size"] for w in line)
|
| 309 |
+
base = float(np.median([w["bottom"] for w in line if w["size"] >= 0.9 * big]))
|
| 310 |
+
prev = None
|
| 311 |
+
for w in line:
|
| 312 |
+
text = w["text"]
|
| 313 |
+
small = w["size"] < 0.85 * big
|
| 314 |
+
if small and w["bottom"] < base - 0.2 * w["size"]:
|
| 315 |
+
text = f"<sup>{text}</sup>"
|
| 316 |
+
elif small and w["bottom"] > base + 0.1 * w["size"]:
|
| 317 |
+
text = f"<sub>{text}</sub>"
|
| 318 |
+
touching = prev is not None and w["x0"] - prev["x1"] < 0.12 * big
|
| 319 |
+
pieces.append((text, *_font_style(w), touching))
|
| 320 |
+
prev = w
|
| 321 |
+
if all(p[1] for p in pieces): # a fully bold segment is a heading or label: no inline marks
|
| 322 |
+
pieces = [(t, False, it, j) for t, _, it, j in pieces]
|
| 323 |
+
if all(p[2] for p in pieces):
|
| 324 |
+
pieces = [(t, b, False, j) for t, b, _, j in pieces]
|
| 325 |
+
out, i = [], 0
|
| 326 |
+
while i < len(pieces):
|
| 327 |
+
bold, italic = pieces[i][1], pieces[i][2]
|
| 328 |
+
j = i
|
| 329 |
+
run = pieces[i][0]
|
| 330 |
+
while j + 1 < len(pieces) and pieces[j + 1][1:3] == (bold, italic):
|
| 331 |
+
j += 1
|
| 332 |
+
run += ("" if pieces[j][3] else " ") + pieces[j][0]
|
| 333 |
+
mark = "**" if bold else ""
|
| 334 |
+
mark += "*" if italic else ""
|
| 335 |
+
out.append(("" if pieces[i][3] and out else " ") + f"{mark}{run}{mark[::-1]}")
|
| 336 |
+
i = j + 1
|
| 337 |
+
return "".join(out).strip()
|
| 338 |
+
|
| 339 |
+
|
| 340 |
+
_INLINE_MARKS = re.compile(r"\*\*\*(.+?)\*\*\*|\*\*(.+?)\*\*|(?<![\w*])\*(?!\s)(.+?)(?<!\s)\*(?![\w*])|</?su[pb]>")
|
| 341 |
+
|
| 342 |
+
|
| 343 |
+
def plain(text):
|
| 344 |
+
"""Text without the inline Markdown marks added from PDF fonts."""
|
| 345 |
+
return _INLINE_MARKS.sub(lambda m: next((g for g in m.groups() if g is not None), ""), text)
|
| 346 |
+
|
| 347 |
+
|
| 348 |
+
def _pdf_heading_by_font(b, words, body):
|
| 349 |
+
"""Use real font sizes (not box heights) to decide headings in born-digital PDFs."""
|
| 350 |
+
if b["type"] not in ("paragraph", "heading"):
|
| 351 |
+
return
|
| 352 |
+
x0, y0, x1, y1 = b["bbox"]
|
| 353 |
+
ws = [w for w in words if x0 - 1 <= w["x0"] and w["x1"] <= x1 + 1 and y0 - 2 <= w["top"] and w["bottom"] <= y1 + 2]
|
| 354 |
+
if not ws:
|
| 355 |
+
return
|
| 356 |
+
size = float(np.median([w["size"] for w in ws]))
|
| 357 |
+
all_bold = all("bold" in w["fontname"].lower() for w in ws)
|
| 358 |
+
short = len(b["text"]) < 120
|
| 359 |
+
if len(b["text"]) < 200 and size >= body * 1.15: # long titles are still titles when set large
|
| 360 |
+
b["type"], b["level"] = "heading", 1 if size >= body * 1.7 else 2 if size >= body * 1.35 else 3
|
| 361 |
+
b["text"] = plain(b["text"])
|
| 362 |
+
elif short and all_bold and len(ws) <= 14:
|
| 363 |
+
b["type"], b["level"] = "heading", 3
|
| 364 |
+
b["text"] = plain(b["text"])
|
| 365 |
+
elif b["type"] == "heading":
|
| 366 |
+
b["type"] = "paragraph"
|
| 367 |
+
b.pop("level", None)
|
| 368 |
+
|
| 369 |
+
|
| 370 |
+
PAGE_NUMBER = re.compile(r"^(page|p\.?|pg\.?)?\s*[-–—(]?\s*([0-9]{1,4}|[ivxlcdm]{1,6})\s*[-–—)]?(\s*(of|/)\s*[0-9]{1,4})?$", re.I)
|
| 371 |
+
|
| 372 |
+
|
| 373 |
+
def mark_page_furniture(blocks, page_height, band=0.08):
|
| 374 |
+
"""Running headers and footers: short text blocks inside the top or bottom band of the page.
|
| 375 |
+
Headings stay in the body (a document title is often near the top of page one)."""
|
| 376 |
+
for b in blocks:
|
| 377 |
+
if b["type"] not in ("paragraph", "list_item") or not b.get("bbox") or len(b.get("text", "")) > 200:
|
| 378 |
+
continue
|
| 379 |
+
x0, y0, x1, y1 = b["bbox"]
|
| 380 |
+
if y1 <= band * page_height:
|
| 381 |
+
b["type"] = "header"
|
| 382 |
+
elif y0 >= (1 - band) * page_height:
|
| 383 |
+
b["type"] = "footer"
|
| 384 |
+
else:
|
| 385 |
+
continue
|
| 386 |
+
if PAGE_NUMBER.match(plain(b["text"]).strip()):
|
| 387 |
+
b["page_number"] = True
|
| 388 |
+
|
| 389 |
+
|
| 390 |
+
def _split_stacked_rows(rows):
|
| 391 |
+
"""Tables without horizontal rules come back from pdfplumber with several records stacked in one
|
| 392 |
+
row, one line each per cell ("8833\n8837\n8838"). When every filled cell of a row holds the same
|
| 393 |
+
number of lines, those lines are separate records: split them into rows."""
|
| 394 |
+
out = []
|
| 395 |
+
for row in rows:
|
| 396 |
+
parts = [str(c).split("\n") if c else [] for c in row]
|
| 397 |
+
counts = {len(p) for p in parts if p}
|
| 398 |
+
if len(counts) == 1 and (k := counts.pop()) > 1 and sum(1 for p in parts if p) >= 2:
|
| 399 |
+
out += [[p[i] if p else "" for p in parts] for i in range(k)]
|
| 400 |
+
else:
|
| 401 |
+
out.append(row)
|
| 402 |
+
return out
|
| 403 |
+
|
| 404 |
+
|
| 405 |
+
def _clean_cell(c):
|
| 406 |
+
return "" if c is None else " ".join(str(c).split())
|
| 407 |
+
|
| 408 |
+
|
| 409 |
+
def _render_pdf_page(path, index, dpi, password):
|
| 410 |
+
import pypdfium2 as pdfium
|
| 411 |
+
pdf_ = pdfium.PdfDocument(str(path), password=password)
|
| 412 |
+
try:
|
| 413 |
+
return pdf_[index].render(scale=dpi / 72).to_pil()
|
| 414 |
+
finally:
|
| 415 |
+
pdf_.close()
|
| 416 |
+
|
| 417 |
+
|
| 418 |
+
def _ocr_pdf_page(path, index, page, model, dpi, password):
|
| 419 |
+
from .ocr.page import analyze
|
| 420 |
+
img = _render_pdf_page(path, index, dpi, password)
|
| 421 |
+
blocks, _ = analyze(img, recognizer(model))
|
| 422 |
+
k = 72 / dpi # pixels -> PDF points
|
| 423 |
+
for b in blocks:
|
| 424 |
+
b["bbox"] = [round(v * k, 1) for v in b["bbox"]]
|
| 425 |
+
b["source"] = "ocr"
|
| 426 |
+
return blocks
|
| 427 |
+
|
| 428 |
+
|
| 429 |
+
def _ocr_pdf_figures(path, index, blocks, model, dpi, password):
|
| 430 |
+
"""Read text inside pictures on a born-digital page; it is stored on the figure block."""
|
| 431 |
+
from .ocr.page import analyze
|
| 432 |
+
figs = [b for b in blocks if b["type"] == "figure"]
|
| 433 |
+
if not figs:
|
| 434 |
+
return
|
| 435 |
+
img = _render_pdf_page(path, index, dpi, password)
|
| 436 |
+
k = dpi / 72
|
| 437 |
+
for f in figs:
|
| 438 |
+
x0, y0, x1, y1 = f["bbox"]
|
| 439 |
+
sub, _ = analyze(img.crop((int(x0 * k), int(y0 * k), int(x1 * k), int(y1 * k))), recognizer(model))
|
| 440 |
+
text = "\n".join(_block_text(b) for b in sub if _block_text(b))
|
| 441 |
+
if text:
|
| 442 |
+
f["text"] = text
|
| 443 |
+
f["text_source"] = "ocr"
|
| 444 |
+
|
| 445 |
+
|
| 446 |
+
# ---------- Word ----------
|
| 447 |
+
|
| 448 |
+
def docx(path, doc, ocr_images=False, model=None):
|
| 449 |
+
import docx as docx_
|
| 450 |
+
from docx.table import Table
|
| 451 |
+
from docx.text.paragraph import Paragraph
|
| 452 |
+
d = docx_.Document(str(path))
|
| 453 |
+
cp = d.core_properties
|
| 454 |
+
for k in ("title", "author", "subject", "keywords", "created", "modified", "last_modified_by"):
|
| 455 |
+
v = getattr(cp, k, None)
|
| 456 |
+
if v:
|
| 457 |
+
doc["metadata"][k] = v.isoformat() if isinstance(v, dt.datetime) else str(v)
|
| 458 |
+
seen = set()
|
| 459 |
+
for s in d.sections:
|
| 460 |
+
for kind, part in (("header", s.header), ("footer", s.footer)):
|
| 461 |
+
if part.is_linked_to_previous:
|
| 462 |
+
continue
|
| 463 |
+
text = "\n".join(p.text for p in part.paragraphs if p.text.strip())
|
| 464 |
+
if text and (kind, text) not in seen:
|
| 465 |
+
seen.add((kind, text))
|
| 466 |
+
doc["blocks"].append({"type": kind, "text": text, "source": "structure"})
|
| 467 |
+
for el in d.element.body.iterchildren():
|
| 468 |
+
tag = el.tag.rsplit("}", 1)[-1]
|
| 469 |
+
if tag == "p":
|
| 470 |
+
b = _docx_paragraph(Paragraph(el, d))
|
| 471 |
+
if b:
|
| 472 |
+
doc["blocks"].append(b)
|
| 473 |
+
for box in el.iter("{http://schemas.openxmlformats.org/wordprocessingml/2006/main}txbxContent"):
|
| 474 |
+
text = "\n".join(Paragraph(q, d).text for q in box.iterchildren() if q.tag.endswith("}p")).strip()
|
| 475 |
+
if text: # text boxes and shapes are not part of paragraph.text
|
| 476 |
+
doc["blocks"].append({"type": "paragraph", "text": text, "source": "structure", "text_box": True})
|
| 477 |
+
elif tag == "tbl":
|
| 478 |
+
doc["blocks"].append({"type": "table", "rows": _docx_table(Table(el, d)), "source": "structure"})
|
| 479 |
+
images = [r.target_part for r in d.part.rels.values() if "image" in r.reltype]
|
| 480 |
+
doc["metadata"]["images"] = len(images)
|
| 481 |
+
if ocr_images:
|
| 482 |
+
_ocr_blobs([im.blob for im in images], doc, model)
|
| 483 |
+
doc["pages"] = 1
|
| 484 |
+
|
| 485 |
+
|
| 486 |
+
def _docx_paragraph(p):
|
| 487 |
+
text = p.text.strip()
|
| 488 |
+
if not text:
|
| 489 |
+
return None
|
| 490 |
+
style = (p.style.name if p.style is not None else "") or ""
|
| 491 |
+
b = {"type": "paragraph", "text": text, "source": "structure"}
|
| 492 |
+
low = style.lower()
|
| 493 |
+
if low == "title":
|
| 494 |
+
b.update(type="heading", level=1)
|
| 495 |
+
elif low.startswith("heading"):
|
| 496 |
+
digits = "".join(ch for ch in style if ch.isdigit())
|
| 497 |
+
b.update(type="heading", level=int(digits) if digits else 1)
|
| 498 |
+
else:
|
| 499 |
+
ppr = p._p.pPr
|
| 500 |
+
num = ppr.numPr if ppr is not None else None
|
| 501 |
+
if num is not None or "list" in low:
|
| 502 |
+
b["type"] = "list_item"
|
| 503 |
+
if num is not None and num.ilvl is not None:
|
| 504 |
+
b["level"] = int(num.ilvl.val)
|
| 505 |
+
elif style[-1:].isdigit(): # "List Bullet 2" -> second level
|
| 506 |
+
b["level"] = int(style[-1]) - 1
|
| 507 |
+
if "number" in low:
|
| 508 |
+
b["ordered"] = True
|
| 509 |
+
if b["type"] == "paragraph" and p.runs and all(r.bold for r in p.runs if r.text.strip()) and len(text) < 100:
|
| 510 |
+
b.update(type="heading", level=3)
|
| 511 |
+
return b
|
| 512 |
+
|
| 513 |
+
|
| 514 |
+
def _docx_table(table):
|
| 515 |
+
rows = []
|
| 516 |
+
for row in table.rows:
|
| 517 |
+
out, last = [], None
|
| 518 |
+
for cell in row.cells:
|
| 519 |
+
if cell._tc is last: # horizontally merged cells repeat the same element
|
| 520 |
+
out.append("")
|
| 521 |
+
continue
|
| 522 |
+
last = cell._tc
|
| 523 |
+
parts = [p.text for p in cell.paragraphs if p.text.strip()]
|
| 524 |
+
for inner in cell.tables:
|
| 525 |
+
parts += [" | ".join(r) for r in _docx_table(inner)]
|
| 526 |
+
out.append(" ".join(" ".join(parts).split()))
|
| 527 |
+
rows.append(out)
|
| 528 |
+
return rows
|
| 529 |
+
|
| 530 |
+
|
| 531 |
+
def _ocr_blobs(blobs, doc, model):
|
| 532 |
+
from PIL import Image
|
| 533 |
+
from .ocr.page import analyze
|
| 534 |
+
for k, blob in enumerate(blobs, 1):
|
| 535 |
+
try:
|
| 536 |
+
img = Image.open(io.BytesIO(blob))
|
| 537 |
+
img.load()
|
| 538 |
+
except Exception:
|
| 539 |
+
doc["warnings"].append(f"embedded image {k} could not be decoded")
|
| 540 |
+
continue
|
| 541 |
+
blocks, _ = analyze(img, recognizer(model))
|
| 542 |
+
for b in blocks:
|
| 543 |
+
b.update(source="ocr", image=k)
|
| 544 |
+
doc["blocks"] += blocks
|
| 545 |
+
|
| 546 |
+
|
| 547 |
+
# ---------- Excel / CSV ----------
|
| 548 |
+
|
| 549 |
+
def _cell_value(v):
|
| 550 |
+
if isinstance(v, dt.datetime) and v.time() == dt.time(0):
|
| 551 |
+
return v.date().isoformat()
|
| 552 |
+
if isinstance(v, (dt.datetime, dt.date, dt.time)):
|
| 553 |
+
return v.isoformat()
|
| 554 |
+
if isinstance(v, float) and v.is_integer():
|
| 555 |
+
return int(v)
|
| 556 |
+
return v
|
| 557 |
+
|
| 558 |
+
|
| 559 |
+
def _regions(grid):
|
| 560 |
+
"""Split a sheet into blocks of cells separated by fully empty rows, then empty columns."""
|
| 561 |
+
def empty(v):
|
| 562 |
+
return v is None or (isinstance(v, str) and not v.strip())
|
| 563 |
+
if not grid:
|
| 564 |
+
return []
|
| 565 |
+
out = []
|
| 566 |
+
rows = [any(not empty(v) for v in r) for r in grid]
|
| 567 |
+
for r0, r1 in _true_runs(rows):
|
| 568 |
+
band = grid[r0:r1]
|
| 569 |
+
cols = [any(not empty(r[c]) for r in band) for c in range(len(band[0]))]
|
| 570 |
+
for c0, c1 in _true_runs(cols):
|
| 571 |
+
block = [r[c0:c1] for r in band]
|
| 572 |
+
keep = [any(not empty(v) for v in r) for r in block]
|
| 573 |
+
first, last = keep.index(True), len(keep) - keep[::-1].index(True)
|
| 574 |
+
out.append((r0 + first, c0, block[first:last]))
|
| 575 |
+
return out
|
| 576 |
+
|
| 577 |
+
|
| 578 |
+
def _true_runs(flags):
|
| 579 |
+
runs, start = [], None
|
| 580 |
+
for i, f in enumerate(list(flags) + [False]):
|
| 581 |
+
if f and start is None:
|
| 582 |
+
start = i
|
| 583 |
+
elif not f and start is not None:
|
| 584 |
+
runs.append((start, i))
|
| 585 |
+
start = None
|
| 586 |
+
return runs
|
| 587 |
+
|
| 588 |
+
|
| 589 |
+
def xlsx(path, doc, ocr_images=False, model=None):
|
| 590 |
+
import openpyxl
|
| 591 |
+
from openpyxl.utils import get_column_letter
|
| 592 |
+
values = openpyxl.load_workbook(path, data_only=True)
|
| 593 |
+
formulas = openpyxl.load_workbook(path, data_only=False)
|
| 594 |
+
props = values.properties
|
| 595 |
+
for k in ("title", "creator", "created", "modified", "lastModifiedBy"):
|
| 596 |
+
v = getattr(props, k, None)
|
| 597 |
+
if v:
|
| 598 |
+
doc["metadata"][k] = v.isoformat() if isinstance(v, dt.datetime) else str(v)
|
| 599 |
+
for ws, wf in zip(values.worksheets, formulas.worksheets):
|
| 600 |
+
info = {"name": ws.title, "state": ws.sheet_state, "dimensions": ws.dimensions,
|
| 601 |
+
"merged": [str(m) for m in ws.merged_cells.ranges], "formulas": {}, "uncalculated": 0}
|
| 602 |
+
grid = []
|
| 603 |
+
for row_v, row_f in zip(ws.iter_rows(), wf.iter_rows()):
|
| 604 |
+
out = []
|
| 605 |
+
for cv, cf in zip(row_v, row_f):
|
| 606 |
+
v = _cell_value(cv.value)
|
| 607 |
+
if isinstance(cf.value, str) and cf.value.startswith("="):
|
| 608 |
+
info["formulas"][cf.coordinate] = cf.value
|
| 609 |
+
if v is None: # saved without cached results (e.g. written by a script)
|
| 610 |
+
info["uncalculated"] += 1
|
| 611 |
+
v = cf.value
|
| 612 |
+
out.append(v)
|
| 613 |
+
grid.append(out)
|
| 614 |
+
# Values of merged ranges live in the top-left cell; the rest are empty, which is correct for tables.
|
| 615 |
+
info["images"] = len(getattr(ws, "_images", []))
|
| 616 |
+
info["charts"] = len(getattr(ws, "_charts", []))
|
| 617 |
+
doc["sheets"].append(info)
|
| 618 |
+
doc["blocks"].append({"type": "heading", "level": 2, "text": ws.title, "sheet": ws.title, "source": "structure"})
|
| 619 |
+
for r0, c0, rows in _regions(grid):
|
| 620 |
+
a = f"{get_column_letter(c0 + 1)}{r0 + 1}"
|
| 621 |
+
b = f"{get_column_letter(c0 + len(rows[0]))}{r0 + len(rows)}"
|
| 622 |
+
doc["blocks"].append({"type": "table", "sheet": ws.title, "range": f"{a}:{b}", "source": "structure",
|
| 623 |
+
"rows": [["" if v is None else v for v in r] for r in rows]})
|
| 624 |
+
if ocr_images:
|
| 625 |
+
blobs = []
|
| 626 |
+
for im in getattr(ws, "_images", []):
|
| 627 |
+
try:
|
| 628 |
+
blobs.append(im._data())
|
| 629 |
+
except Exception:
|
| 630 |
+
pass
|
| 631 |
+
_ocr_blobs(blobs, doc, model)
|
| 632 |
+
doc["pages"] = len(values.worksheets)
|
| 633 |
+
|
| 634 |
+
|
| 635 |
+
def delimited(path, doc):
|
| 636 |
+
raw = Path(path).read_bytes()
|
| 637 |
+
for enc in ("utf-8-sig", "utf-16", "cp1252", "latin-1"):
|
| 638 |
+
try:
|
| 639 |
+
text = raw.decode(enc)
|
| 640 |
+
break
|
| 641 |
+
except UnicodeDecodeError:
|
| 642 |
+
continue
|
| 643 |
+
try:
|
| 644 |
+
dialect = csv.Sniffer().sniff(text[:20000], delimiters=",;\t|")
|
| 645 |
+
except csv.Error:
|
| 646 |
+
dialect = csv.excel_tab if Path(path).suffix.lower() == ".tsv" else csv.excel
|
| 647 |
+
rows = [r for r in csv.reader(io.StringIO(text), dialect)]
|
| 648 |
+
doc["blocks"].append({"type": "table", "rows": rows, "source": "structure"})
|
| 649 |
+
doc["metadata"].update({"encoding": enc, "delimiter": dialect.delimiter, "rows": len(rows)})
|
| 650 |
+
doc["pages"] = 1
|
| 651 |
+
|
| 652 |
+
|
| 653 |
+
def text_file(path, doc):
|
| 654 |
+
text = Path(path).read_text(encoding="utf-8", errors="replace")
|
| 655 |
+
for para in [p.strip() for p in text.split("\n\n") if p.strip()]:
|
| 656 |
+
if para.startswith("#"):
|
| 657 |
+
level = len(para) - len(para.lstrip("#"))
|
| 658 |
+
doc["blocks"].append({"type": "heading", "level": level, "text": para.lstrip("# ").strip(), "source": "structure"})
|
| 659 |
+
else:
|
| 660 |
+
doc["blocks"].append({"type": "paragraph", "text": para, "source": "structure"})
|
| 661 |
+
doc["pages"] = 1
|
| 662 |
+
|
| 663 |
+
|
| 664 |
+
# ---------- output ----------
|
| 665 |
+
|
| 666 |
+
def _block_text(b):
|
| 667 |
+
if b["type"] == "table":
|
| 668 |
+
return "\n".join("\t".join(str(c) for c in row) for row in b["rows"])
|
| 669 |
+
return b.get("text", "")
|
| 670 |
+
|
| 671 |
+
|
| 672 |
+
def to_text(doc):
|
| 673 |
+
return "\n\n".join(plain(t) for t in (_block_text(b) for b in doc["blocks"]) if t)
|
| 674 |
+
|
| 675 |
+
|
| 676 |
+
def _md_cell(v):
|
| 677 |
+
return str(v).replace("|", "\\|").replace("\n", " ")
|
| 678 |
+
|
| 679 |
+
|
| 680 |
+
def _html_table(rows):
|
| 681 |
+
esc = lambda v: html.escape(str(v)).replace("\n", "<br>") # noqa: E731
|
| 682 |
+
head, *body = rows
|
| 683 |
+
out = ["<table>", "<tr>" + "".join(f"<th>{esc(c)}</th>" for c in head) + "</tr>"]
|
| 684 |
+
out += ["<tr>" + "".join(f"<td>{esc(c)}</td>" for c in r) + "</tr>" for r in body]
|
| 685 |
+
return "\n".join(out + ["</table>"])
|
| 686 |
+
|
| 687 |
+
|
| 688 |
+
def to_markdown(doc, tables="markdown", page_marks=True):
|
| 689 |
+
"""tables="html" writes <table> elements, which keep cells containing pipes or line breaks intact."""
|
| 690 |
+
out, page = [], None
|
| 691 |
+
for b in doc["blocks"]:
|
| 692 |
+
if (page_marks and b.get("page") and doc["pages"] > 1 and b["page"] != page
|
| 693 |
+
and doc["format"] not in ("xlsx", "xlsm", "xltx")):
|
| 694 |
+
page = b["page"]
|
| 695 |
+
out.append(f"<!-- page {page} -->")
|
| 696 |
+
t = b["type"]
|
| 697 |
+
if t == "heading":
|
| 698 |
+
out.append("#" * min(6, max(1, b.get("level", 1))) + " " + b["text"])
|
| 699 |
+
elif t == "list_item":
|
| 700 |
+
text = re.sub(r"^(•|\*|-)\s+", "", b["text"])
|
| 701 |
+
prefix = "" if re.match(r"^\d{1,3}[.)]", text) else "1. " if b.get("ordered") else "- "
|
| 702 |
+
out.append(" " * b.get("level", 0) + prefix + text)
|
| 703 |
+
elif t == "table":
|
| 704 |
+
rows = b["rows"]
|
| 705 |
+
if not rows:
|
| 706 |
+
continue
|
| 707 |
+
n = max(len(r) for r in rows)
|
| 708 |
+
rows = [list(r) + [""] * (n - len(r)) for r in rows]
|
| 709 |
+
if tables == "html":
|
| 710 |
+
out.append(_html_table(rows))
|
| 711 |
+
continue
|
| 712 |
+
lines = ["| " + " | ".join(_md_cell(c) for c in rows[0]) + " |", "|" + " --- |" * n]
|
| 713 |
+
lines += ["| " + " | ".join(_md_cell(c) for c in r) + " |" for r in rows[1:]]
|
| 714 |
+
out.append("\n".join(lines))
|
| 715 |
+
elif t == "figure":
|
| 716 |
+
out.append("[figure]" + (f"\n\n> {b['text']}" if b.get("text") else ""))
|
| 717 |
+
elif t in ("header", "footer"):
|
| 718 |
+
out.append(f"<!-- {t}: {b['text']} -->")
|
| 719 |
+
else:
|
| 720 |
+
out.append(b["text"])
|
| 721 |
+
return "\n\n".join(out) + "\n"
|
docparse/extractbench.py
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""ExtractBench (LlamaIndex) integration: a two-stage parse -> local LLM extraction pipeline.
|
| 2 |
+
|
| 3 |
+
ExtractBench scores schema-guided extraction (document + JSON Schema -> JSON). A parser alone
|
| 4 |
+
cannot fill a schema, so docparse takes part the way ExtractBench's own two-stage baselines do:
|
| 5 |
+
it is the *parse stage*, and a language model reads its Markdown and returns the JSON.
|
| 6 |
+
|
| 7 |
+
To isolate what the parser contributes, the same model, prompt, context budget and scorer are
|
| 8 |
+
used with two parse stages:
|
| 9 |
+
docparse_extract_<model> docparse Markdown (headings, HTML tables, OCR for scanned pages)
|
| 10 |
+
pypdf_extract_<model> plain pypdf text, the simplest local baseline
|
| 11 |
+
|
| 12 |
+
The model is served by any OpenAI-compatible endpoint (e.g. llama.cpp's server on a CPU):
|
| 13 |
+
python -m llama_cpp.server --model qwen2.5-3b-instruct-q4_k_m.gguf --n_ctx 12288 --port 8088
|
| 14 |
+
DOCPARSE_LLM_URL=http://127.0.0.1:8088 python scripts/extractbench_docparse.py run docparse_extract_local --test
|
| 15 |
+
"""
|
| 16 |
+
import json
|
| 17 |
+
from pathlib import Path
|
| 18 |
+
from typing import Any
|
| 19 |
+
|
| 20 |
+
from extract_bench.extensions import register_pipeline, register_provider
|
| 21 |
+
from extract_bench.inference.providers.extract.vllm_extract import VLLMExtractProvider
|
| 22 |
+
from extract_bench.schemas.pipeline import PipelineSpec
|
| 23 |
+
from extract_bench.schemas.product import ProductType
|
| 24 |
+
|
| 25 |
+
from .extract import extract, to_markdown
|
| 26 |
+
|
| 27 |
+
CHARS_PER_TOKEN = 2.5 # conservative: JSON schemas and numbers tokenise densely
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def docparse_pages(path):
|
| 31 |
+
doc = extract(path)
|
| 32 |
+
pages = []
|
| 33 |
+
for n in range(1, max(1, doc["pages"]) + 1):
|
| 34 |
+
blocks = [b for b in doc["blocks"] if b.get("page", 1) == n]
|
| 35 |
+
pages.append(to_markdown({**doc, "blocks": blocks, "pages": 1}, tables="html", page_marks=False).strip())
|
| 36 |
+
return pages
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def pypdf_pages(path):
|
| 40 |
+
from pypdf import PdfReader
|
| 41 |
+
return [(p.extract_text() or "").strip() for p in PdfReader(str(path)).pages]
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
PARSERS = {"docparse": docparse_pages, "pypdf": pypdf_pages}
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
@register_provider("parsed_text_local_llm")
|
| 48 |
+
class ParsedTextLocalLLM(VLLMExtractProvider):
|
| 49 |
+
"""ExtractBench's self-hosted provider, fed parsed page text instead of page images.
|
| 50 |
+
|
| 51 |
+
Extra config: parser ("docparse" | "pypdf"), n_ctx (served context length, tokens).
|
| 52 |
+
Everything else (JSON salvage, schema pruning, normalisation, scoring) is the harness's own.
|
| 53 |
+
"""
|
| 54 |
+
|
| 55 |
+
def __init__(self, provider_name: str, base_config: dict[str, Any] | None = None):
|
| 56 |
+
super().__init__(provider_name, base_config)
|
| 57 |
+
self._parser = self.base_config.get("parser", "docparse")
|
| 58 |
+
self._n_ctx = int(self.base_config.get("n_ctx", 16384))
|
| 59 |
+
|
| 60 |
+
def _render_page_images(self, source_path: Path) -> list[str]:
|
| 61 |
+
return [f"<!-- Page {i} -->\n{t}" for i, t in enumerate(PARSERS[self._parser](source_path), 1)]
|
| 62 |
+
|
| 63 |
+
def _build_user_content(self, pages: list[str], schema: dict[str, Any], compact_schema: bool = False):
|
| 64 |
+
schema_text = json.dumps(schema, separators=(",", ":")) if compact_schema else json.dumps(schema, indent=2)
|
| 65 |
+
return ("Extract every field from the document text below according to the JSON schema.\n\n"
|
| 66 |
+
f"JSON schema:\n{schema_text}\n\nDocument text:\n" + "\n\n".join(pages) + "\n\n"
|
| 67 |
+
"Return only one valid JSON object matching the schema. Do not wrap it in markdown fences.\n\n"
|
| 68 |
+
f"{self._user_instruction}")
|
| 69 |
+
|
| 70 |
+
@staticmethod
|
| 71 |
+
def _fit_pages(pages, budget):
|
| 72 |
+
kept, used = [], 0
|
| 73 |
+
for p in pages:
|
| 74 |
+
if used + len(p) > budget:
|
| 75 |
+
if budget - used > 200:
|
| 76 |
+
kept.append(p[: budget - used])
|
| 77 |
+
used = budget
|
| 78 |
+
return kept, used, len(pages) - len(kept)
|
| 79 |
+
kept.append(p)
|
| 80 |
+
used += len(p) + 2
|
| 81 |
+
return kept, used, 0
|
| 82 |
+
|
| 83 |
+
def _call_api_fitting_context(self, schema: dict[str, Any], pages: list[str]) -> dict[str, Any]:
|
| 84 |
+
"""Fit schema + text + output into the served context: compact the schema, prune its trailing
|
| 85 |
+
fields if it alone is too large, then cut the document text to the remaining budget.
|
| 86 |
+
Both parse stages get exactly the same budget."""
|
| 87 |
+
cur, pruned = schema, 0
|
| 88 |
+
out_tokens = min(self._max_tokens, self._n_ctx // 4)
|
| 89 |
+
overhead = len(self._system_prompt) + len(self._user_instruction) + 400
|
| 90 |
+
while True:
|
| 91 |
+
schema_chars = len(json.dumps(cur, separators=(",", ":")))
|
| 92 |
+
budget = int((self._n_ctx - out_tokens) * CHARS_PER_TOKEN) - schema_chars - overhead
|
| 93 |
+
if budget > 2000 or (nxt := self._prune_schema(cur)) is None:
|
| 94 |
+
break
|
| 95 |
+
cur, pruned = nxt, pruned + 1
|
| 96 |
+
for _ in range(4):
|
| 97 |
+
kept, used, cut = self._fit_pages(pages, budget)
|
| 98 |
+
try:
|
| 99 |
+
out = self._call_api(cur, kept, max_tokens=out_tokens, compact_schema=True)
|
| 100 |
+
break
|
| 101 |
+
except Exception as e: # the server rejects requests over its context window: halve the text
|
| 102 |
+
if not any(k in str(e).lower() for k in ("context", "exceed", "400")):
|
| 103 |
+
raise
|
| 104 |
+
budget //= 2
|
| 105 |
+
else:
|
| 106 |
+
raise RuntimeError("request does not fit the model context even with a small part of the text")
|
| 107 |
+
out["_config"].update(parser=self._parser, n_ctx=self._n_ctx, text_chars_sent=used)
|
| 108 |
+
if cut:
|
| 109 |
+
out["pages_truncated"], out["truncated"] = cut, True
|
| 110 |
+
if pruned:
|
| 111 |
+
out["schema_prune_rounds"], out["truncated"] = pruned, True
|
| 112 |
+
return out
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
for parser in PARSERS:
|
| 116 |
+
register_pipeline(PipelineSpec(
|
| 117 |
+
pipeline_name=f"{parser}_extract_local", provider_name="parsed_text_local_llm",
|
| 118 |
+
product_type=ProductType.EXTRACT,
|
| 119 |
+
config={"parser": parser, "endpoint_env_var": "DOCPARSE_LLM_URL", "model": "qwen2.5-3b", "n_ctx": 12288,
|
| 120 |
+
"max_tokens": 4096, "structured_output": False, "temperature": 0.0, "timeout_s": 3600}))
|
docparse/ocr/__init__.py
ADDED
|
File without changes
|
docparse/ocr/charset.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Characters the OCR network can emit. Index 0 is the CTC blank."""
|
| 2 |
+
import unicodedata
|
| 3 |
+
|
| 4 |
+
CHARS = "".join(chr(c) for c in range(32, 127)) + "•€£°©"
|
| 5 |
+
BLANK = 0
|
| 6 |
+
INDEX = {c: i + 1 for i, c in enumerate(CHARS)}
|
| 7 |
+
CLASSES = len(CHARS) + 1
|
| 8 |
+
|
| 9 |
+
_MAP = {"‘": "'", "’": "'", "‚": "'", "“": '"', "”": '"', "„": '"',
|
| 10 |
+
"–": "-", "—": "-", "−": "-", "…": "...", " ": " ", "\t": " ",
|
| 11 |
+
"·": "•", "●": "•", "•": "•", "×": "x"}
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def normalize(text):
|
| 15 |
+
"""Map text into CHARS: fold typographic punctuation and accents, drop the rest."""
|
| 16 |
+
out = []
|
| 17 |
+
for ch in text:
|
| 18 |
+
ch = _MAP.get(ch, ch)
|
| 19 |
+
if all(c in INDEX for c in ch):
|
| 20 |
+
out.append(ch)
|
| 21 |
+
continue
|
| 22 |
+
base = unicodedata.normalize("NFKD", ch).encode("ascii", "ignore").decode()
|
| 23 |
+
out.append(base if base and all(c in INDEX for c in base) else "")
|
| 24 |
+
return "".join(out)
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def encode(text):
|
| 28 |
+
return [INDEX[c] for c in text if c in INDEX]
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def decode_greedy(ids):
|
| 32 |
+
"""Collapse repeats, drop blanks."""
|
| 33 |
+
out, prev = [], BLANK
|
| 34 |
+
for i in ids:
|
| 35 |
+
if i != prev and i != BLANK:
|
| 36 |
+
out.append(CHARS[i - 1])
|
| 37 |
+
prev = i
|
| 38 |
+
return "".join(out)
|
docparse/ocr/metrics.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Character and word error rates (edit distance / reference length)."""
|
| 2 |
+
|
| 3 |
+
|
| 4 |
+
def edit_distance(a, b):
|
| 5 |
+
if len(a) < len(b):
|
| 6 |
+
a, b = b, a
|
| 7 |
+
prev = list(range(len(b) + 1))
|
| 8 |
+
for i, ca in enumerate(a, 1):
|
| 9 |
+
cur = [i]
|
| 10 |
+
for j, cb in enumerate(b, 1):
|
| 11 |
+
cur.append(min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + (ca != cb)))
|
| 12 |
+
prev = cur
|
| 13 |
+
return prev[-1]
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def cer(pred, ref):
|
| 17 |
+
return edit_distance(pred, ref) / max(1, len(ref))
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def wer(pred, ref):
|
| 21 |
+
return edit_distance(pred.split(), ref.split()) / max(1, len(ref.split()))
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def summary(preds, refs):
|
| 25 |
+
chars = sum(edit_distance(p, r) for p, r in zip(preds, refs))
|
| 26 |
+
words = sum(edit_distance(p.split(), r.split()) for p, r in zip(preds, refs))
|
| 27 |
+
return {"lines": len(refs), "cer": round(chars / max(1, sum(len(r) for r in refs)), 5),
|
| 28 |
+
"wer": round(words / max(1, sum(len(r.split()) for r in refs)), 5),
|
| 29 |
+
"line_exact": round(sum(p == r for p, r in zip(preds, refs)) / max(1, len(refs)), 4)}
|
docparse/ocr/model.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""A small CRNN line recognizer: convolutions read glyph shapes, a BiLSTM reads context, CTC aligns."""
|
| 2 |
+
import numpy as np
|
| 3 |
+
import torch
|
| 4 |
+
from torch import nn
|
| 5 |
+
|
| 6 |
+
from .charset import CLASSES, decode_greedy
|
| 7 |
+
from .render import HEIGHT
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def _block(cin, cout, stride=1):
|
| 11 |
+
return [nn.Conv2d(cin, cout, 3, stride=stride, padding=1, bias=False), nn.BatchNorm2d(cout), nn.ReLU(inplace=True)]
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
class LineOCR(nn.Module):
|
| 15 |
+
def __init__(self, width=32, hidden=128):
|
| 16 |
+
super().__init__()
|
| 17 |
+
self.config = {"width": width, "hidden": hidden}
|
| 18 |
+
w = width
|
| 19 |
+
self.cnn = nn.Sequential(
|
| 20 |
+
*_block(1, w, 2), # 16 x W/2 (strided stem keeps CPU cost low)
|
| 21 |
+
*_block(w, 2 * w), nn.MaxPool2d(2), # 8 x W/4
|
| 22 |
+
*_block(2 * w, 4 * w), nn.MaxPool2d((2, 1)), # 4 x W/4
|
| 23 |
+
*_block(4 * w, 4 * w), nn.MaxPool2d((2, 1)), # 2 x W/4
|
| 24 |
+
*_block(4 * w, 8 * w),
|
| 25 |
+
nn.Conv2d(8 * w, 8 * w, (2, 1)), nn.BatchNorm2d(8 * w), nn.ReLU(inplace=True)) # 1 x W/4
|
| 26 |
+
self.rnn = nn.LSTM(8 * w, hidden, num_layers=2, bidirectional=True, batch_first=True, dropout=0.1)
|
| 27 |
+
self.head = nn.Linear(2 * hidden, CLASSES)
|
| 28 |
+
|
| 29 |
+
def forward(self, x):
|
| 30 |
+
"""x: (B,1,HEIGHT,W) -> log-probs (B, W/4, CLASSES)."""
|
| 31 |
+
f = self.cnn(x).squeeze(2).transpose(1, 2)
|
| 32 |
+
f, _ = self.rnn(f)
|
| 33 |
+
return self.head(f).log_softmax(-1)
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def batch_tensor(images, pad_value=None):
|
| 37 |
+
"""List of uint8 (HEIGHT, w) arrays -> float tensor (B,1,HEIGHT,maxW), dark text made positive."""
|
| 38 |
+
wmax = max(a.shape[1] for a in images)
|
| 39 |
+
wmax = (wmax + 3) // 4 * 4
|
| 40 |
+
out = np.zeros((len(images), 1, HEIGHT, wmax), np.float32)
|
| 41 |
+
for i, a in enumerate(images):
|
| 42 |
+
x = 1.0 - a.astype(np.float32) / 255.0
|
| 43 |
+
if x[:, :2].mean() + x[:, -2:].mean() > 1.0 and x.mean() > 0.5: # light text on dark: flip to dark-on-light
|
| 44 |
+
x = 1.0 - x
|
| 45 |
+
bg = np.median(np.concatenate([x[0], x[-1], x[:, 0], x[:, -1]]))
|
| 46 |
+
x = x - bg # background to zero, so right padding matches it
|
| 47 |
+
out[i, 0, :, :a.shape[1]] = x
|
| 48 |
+
return torch.from_numpy(out), [a.shape[1] // 4 for a in images]
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
class Recognizer:
|
| 52 |
+
"""Load trained weights and read line images."""
|
| 53 |
+
|
| 54 |
+
def __init__(self, path, device="cpu"):
|
| 55 |
+
data = torch.load(path, map_location=device, weights_only=True)
|
| 56 |
+
self.net = LineOCR(**data["config"]).to(device).eval()
|
| 57 |
+
self.net.load_state_dict(data["state"])
|
| 58 |
+
self.device = device
|
| 59 |
+
self.info = data.get("info", {})
|
| 60 |
+
|
| 61 |
+
@torch.inference_mode()
|
| 62 |
+
def read(self, images, batch=32):
|
| 63 |
+
"""images: uint8 grayscale arrays of height HEIGHT -> [(text, confidence)]."""
|
| 64 |
+
results = [None] * len(images)
|
| 65 |
+
order = sorted(range(len(images)), key=lambda i: images[i].shape[1])
|
| 66 |
+
for s in range(0, len(order), batch):
|
| 67 |
+
idx = order[s:s + batch]
|
| 68 |
+
x, lens = batch_tensor([images[i] for i in idx])
|
| 69 |
+
logp = self.net(x.to(self.device)).cpu()
|
| 70 |
+
for k, i in enumerate(idx):
|
| 71 |
+
lp = logp[k, :max(1, lens[k])]
|
| 72 |
+
best = lp.argmax(-1)
|
| 73 |
+
text = decode_greedy(best.tolist())
|
| 74 |
+
emitted = best != 0
|
| 75 |
+
conf = float(lp.max(-1).values[emitted].exp().mean()) if emitted.any() else 0.0
|
| 76 |
+
results[i] = (text.strip(), round(conf, 4))
|
| 77 |
+
return results
|
docparse/ocr/page.py
ADDED
|
@@ -0,0 +1,622 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Page images -> text lines, ruled tables and layout blocks (classical image processing, numpy only).
|
| 2 |
+
|
| 3 |
+
The neural part is only the line recognizer (model.py). Finding lines, columns and
|
| 4 |
+
tables here is rule-based, so its failure modes are predictable:
|
| 5 |
+
- ruled tables are read cell by cell; borderless tables are found from aligned gaps;
|
| 6 |
+
- multi-column text is split on wide vertical gaps;
|
| 7 |
+
- photos/figures are reported as `figure` blocks and not read.
|
| 8 |
+
"""
|
| 9 |
+
import re
|
| 10 |
+
|
| 11 |
+
import numpy as np
|
| 12 |
+
from PIL import Image
|
| 13 |
+
|
| 14 |
+
from .render import HEIGHT
|
| 15 |
+
|
| 16 |
+
LIST_RE = re.compile(r"^((•|-|\*|\d{1,3}[.)]|\(?[a-zA-Z][.)]|\([ivx]+\))\s+|\d{1,3}[.)](?=[A-Za-z]))")
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
# ---------- small numpy morphology ----------
|
| 20 |
+
|
| 21 |
+
def _runs(mask):
|
| 22 |
+
"""Start/end (exclusive) of True runs in a 1-D boolean array."""
|
| 23 |
+
m = np.concatenate([[False], mask, [False]]).astype(np.int8)
|
| 24 |
+
d = np.diff(m)
|
| 25 |
+
return list(zip(np.flatnonzero(d == 1), np.flatnonzero(d == -1)))
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def _box_mean(a, r):
|
| 29 |
+
"""Mean over a (2r+1)^2 window via an integral image."""
|
| 30 |
+
p = np.pad(a, r + 1, mode="edge").astype(np.float64)
|
| 31 |
+
s = p.cumsum(0).cumsum(1)
|
| 32 |
+
k = 2 * r + 1
|
| 33 |
+
tot = s[k:, k:] - s[:-k, k:] - s[k:, :-k] + s[:-k, :-k]
|
| 34 |
+
return (tot / (k * k))[:a.shape[0], :a.shape[1]]
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def _open_1d(mask, k, axis):
|
| 38 |
+
"""Morphological opening with a 1 x k line: keeps only runs at least k long."""
|
| 39 |
+
m = mask if axis == 1 else mask.T
|
| 40 |
+
c = np.pad(m.astype(np.int32), ((0, 0), (1, 0))).cumsum(1)
|
| 41 |
+
full = (c[:, k:] - c[:, :-k]) == k # window starting at j is all ink
|
| 42 |
+
out = np.zeros_like(m)
|
| 43 |
+
c2 = np.pad(full.astype(np.int32), ((0, 0), (k, 0))).cumsum(1)
|
| 44 |
+
cover = (c2[:, k:] - c2[:, :-k]) > 0 # any full window covers this pixel
|
| 45 |
+
out[:, :cover.shape[1]] = cover[:, :m.shape[1]]
|
| 46 |
+
return out if axis == 1 else out.T
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def components(ink):
|
| 50 |
+
"""8-connected components from row runs. Returns rows, starts, ends and a list of run-index groups."""
|
| 51 |
+
H = ink.shape[0]
|
| 52 |
+
m = np.pad(ink.astype(np.int8), ((0, 0), (1, 1)))
|
| 53 |
+
d = np.diff(m, axis=1)
|
| 54 |
+
rows, starts = np.nonzero(d == 1)
|
| 55 |
+
_, ends = np.nonzero(d == -1)
|
| 56 |
+
n = len(rows)
|
| 57 |
+
parent = np.arange(n)
|
| 58 |
+
|
| 59 |
+
def find(i):
|
| 60 |
+
while parent[i] != i:
|
| 61 |
+
parent[i] = parent[parent[i]]
|
| 62 |
+
i = parent[i]
|
| 63 |
+
return i
|
| 64 |
+
row_start = np.searchsorted(rows, np.arange(H + 1))
|
| 65 |
+
for y in range(1, H):
|
| 66 |
+
i, a1 = row_start[y - 1], row_start[y]
|
| 67 |
+
j, b1 = row_start[y], row_start[y + 1]
|
| 68 |
+
while i < a1 and j < b1: # runs in consecutive rows touch (diagonals included)
|
| 69 |
+
if starts[i] <= ends[j] and starts[j] <= ends[i]:
|
| 70 |
+
ri, rj = find(i), find(j)
|
| 71 |
+
if ri != rj:
|
| 72 |
+
parent[ri] = rj
|
| 73 |
+
if ends[i] < ends[j]:
|
| 74 |
+
i += 1
|
| 75 |
+
else:
|
| 76 |
+
j += 1
|
| 77 |
+
roots = np.array([find(i) for i in range(n)], dtype=np.int64)
|
| 78 |
+
order = np.argsort(roots, kind="stable")
|
| 79 |
+
groups = np.split(order, np.flatnonzero(np.diff(roots[order])) + 1) if n else []
|
| 80 |
+
return rows, starts, ends, groups
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def drop_components(ink, test):
|
| 84 |
+
"""Remove every component for which test(h, w, pixels) is true."""
|
| 85 |
+
rows, starts, ends, groups = components(ink)
|
| 86 |
+
out = ink.copy()
|
| 87 |
+
lengths = ends - starts
|
| 88 |
+
for grp in groups:
|
| 89 |
+
h = rows[grp].max() + 1 - rows[grp].min()
|
| 90 |
+
w = ends[grp].max() - starts[grp].min()
|
| 91 |
+
if test(h, w, int(lengths[grp].sum())):
|
| 92 |
+
for k in grp:
|
| 93 |
+
out[rows[k], starts[k]:ends[k]] = False
|
| 94 |
+
return out
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
def remove_frames(ink, th):
|
| 98 |
+
"""Drop outline shapes (rounded boxes, circles, slide decorations): components much larger than
|
| 99 |
+
a letter in both directions whose bounding box is mostly empty."""
|
| 100 |
+
return drop_components(ink, lambda h, w, px: h > 2.5 * th and w > 2.5 * th and px < 0.08 * h * w)
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
# ---------- preprocessing ----------
|
| 104 |
+
|
| 105 |
+
def load_gray(img):
|
| 106 |
+
if not isinstance(img, Image.Image):
|
| 107 |
+
img = Image.open(img)
|
| 108 |
+
if img.mode in ("RGBA", "LA", "P"):
|
| 109 |
+
img = img.convert("RGBA")
|
| 110 |
+
bg = Image.new("RGBA", img.size, (255, 255, 255, 255))
|
| 111 |
+
img = Image.alpha_composite(bg, img)
|
| 112 |
+
g = np.asarray(img.convert("L"), dtype=np.float32)
|
| 113 |
+
if np.median(g) < 110: # dark mode / inverted scan
|
| 114 |
+
g = 255 - g
|
| 115 |
+
return g
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
def binarize(g):
|
| 119 |
+
"""Ink mask. Dark text on light paper, plus light text on dark fills (buttons, banners, headers).
|
| 120 |
+
Specks of scan noise (components of a few pixels) are removed."""
|
| 121 |
+
r = max(7, min(g.shape) // 60)
|
| 122 |
+
local = _box_mean(g, r)
|
| 123 |
+
gs, k = g, 12.0
|
| 124 |
+
if 1.4826 * float(np.median(np.abs(g - local))) > 6: # grainy scan: noise level measured robustly (MAD)
|
| 125 |
+
gs = _box_mean(g, 1) # 3x3 smoothing: grain averages out, strokes do not
|
| 126 |
+
k = max(12.0, 3.0 * 1.4826 * float(np.median(np.abs(gs - local))))
|
| 127 |
+
dark_bg = _box_mean((g < 150).astype(np.float32), r) > 0.55 # mostly dark surroundings (outside edges stay < 0.5)
|
| 128 |
+
ink = np.where(dark_bg, (gs > local + max(18.0, k)) & (gs > 120), (gs < local - k) & (gs < 215))
|
| 129 |
+
return drop_components(ink, lambda h, w, px: px <= 4)
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
def _deskew_angle(ink):
|
| 133 |
+
small = Image.fromarray((ink * 255).astype(np.uint8))
|
| 134 |
+
scale = 900 / max(small.size)
|
| 135 |
+
if scale < 1:
|
| 136 |
+
small = small.resize((int(small.width * scale), int(small.height * scale)), Image.BILINEAR)
|
| 137 |
+
best, best_a = -1.0, 0.0
|
| 138 |
+
for a in np.arange(-4, 4.01, 0.25):
|
| 139 |
+
prof = np.asarray(small.rotate(a, resample=Image.BILINEAR), dtype=np.float32).sum(1)
|
| 140 |
+
score = float(np.square(np.diff(prof)).sum())
|
| 141 |
+
if score > best:
|
| 142 |
+
best, best_a = score, float(a)
|
| 143 |
+
return best_a
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
def prepare(img, deskew=True):
|
| 147 |
+
"""-> gray (float), ink mask, scale applied, deskew angle."""
|
| 148 |
+
g = load_gray(img)
|
| 149 |
+
scale = 1.0
|
| 150 |
+
if max(g.shape) < 1100: # small screenshots: upsample so thin strokes survive binarization
|
| 151 |
+
scale = 2.0
|
| 152 |
+
g = np.asarray(Image.fromarray(g.astype(np.uint8)).resize((g.shape[1] * 2, g.shape[0] * 2), Image.BICUBIC),
|
| 153 |
+
dtype=np.float32)
|
| 154 |
+
ink = binarize(g)
|
| 155 |
+
angle = 0.0
|
| 156 |
+
if deskew and ink.any():
|
| 157 |
+
angle = _deskew_angle(ink)
|
| 158 |
+
if abs(angle) >= 0.25:
|
| 159 |
+
g = np.asarray(Image.fromarray(g.astype(np.uint8)).rotate(angle, resample=Image.BICUBIC, fillcolor=255),
|
| 160 |
+
dtype=np.float32)
|
| 161 |
+
ink = binarize(g)
|
| 162 |
+
return g, ink, scale, angle
|
| 163 |
+
|
| 164 |
+
|
| 165 |
+
def text_height(ink):
|
| 166 |
+
"""Typical text-line height. Row bands give it on plain pages; frames, logos and shapes merge
|
| 167 |
+
bands, so it is capped by the length of vertical letter strokes (stems ~ 0.7 x line height)."""
|
| 168 |
+
bands = [b - a for a, b in _runs(ink.sum(1) > max(1, ink.shape[1] * 0.002)) if b - a >= 4]
|
| 169 |
+
th = float(np.median(bands)) if bands else 20.0
|
| 170 |
+
sample = ink[:, ::3].T # every third column is plenty
|
| 171 |
+
m = np.pad(sample.astype(np.int8), ((0, 0), (1, 1)))
|
| 172 |
+
d = np.diff(m, axis=1)
|
| 173 |
+
runs = np.flatnonzero(d.ravel() == -1) - np.flatnonzero(d.ravel() == 1)
|
| 174 |
+
runs = runs[(runs >= 5) & (runs < ink.shape[0] // 6)]
|
| 175 |
+
if len(runs) > 200:
|
| 176 |
+
th = min(th, 1.6 * float(np.percentile(runs, 90)))
|
| 177 |
+
return th
|
| 178 |
+
|
| 179 |
+
|
| 180 |
+
# ---------- ruled tables ----------
|
| 181 |
+
|
| 182 |
+
def _segments(mask, axis):
|
| 183 |
+
"""Line pixels -> merged segments (pos0, pos1, start, end) along `axis` (1: horizontal lines)."""
|
| 184 |
+
segs = []
|
| 185 |
+
m = mask if axis == 1 else mask.T
|
| 186 |
+
for i in np.flatnonzero(m.any(1)):
|
| 187 |
+
for s, e in _runs(m[i]):
|
| 188 |
+
for seg in segs:
|
| 189 |
+
if seg[1] >= i - 1 and s < seg[3] and e > seg[2]:
|
| 190 |
+
seg[1] = i
|
| 191 |
+
seg[2], seg[3] = min(seg[2], s), max(seg[3], e)
|
| 192 |
+
break
|
| 193 |
+
else:
|
| 194 |
+
segs.append([i, i, s, e])
|
| 195 |
+
return segs
|
| 196 |
+
|
| 197 |
+
|
| 198 |
+
def find_tables(ink, th):
|
| 199 |
+
"""Grid tables from long horizontal and vertical rules. Returns (tables, rule mask)."""
|
| 200 |
+
h = _open_1d(ink, int(max(40, 5 * th)), 1)
|
| 201 |
+
v = _open_1d(ink, int(max(20, 1.6 * th)), 0)
|
| 202 |
+
hs, vs = _segments(h, 1), _segments(v, 0)
|
| 203 |
+
# Union-find over rule segments that touch each other.
|
| 204 |
+
items = [("h", s) for s in hs] + [("v", s) for s in vs]
|
| 205 |
+
parent = list(range(len(items)))
|
| 206 |
+
|
| 207 |
+
def find(i):
|
| 208 |
+
while parent[i] != i:
|
| 209 |
+
parent[i] = parent[parent[i]]
|
| 210 |
+
i = parent[i]
|
| 211 |
+
return i
|
| 212 |
+
tol = 4
|
| 213 |
+
for i, (ki, a) in enumerate(items):
|
| 214 |
+
if ki != "h":
|
| 215 |
+
continue
|
| 216 |
+
for j, (kj, b) in enumerate(items):
|
| 217 |
+
if kj != "v":
|
| 218 |
+
continue
|
| 219 |
+
# h: rows a0..a1, cols a2..a3 ; v: cols b0..b1, rows b2..b3
|
| 220 |
+
if b[0] - tol <= a[3] and b[1] + tol >= a[2] and a[0] + tol >= b[2] and a[1] - tol <= b[3]:
|
| 221 |
+
parent[find(i)] = find(j)
|
| 222 |
+
groups = {}
|
| 223 |
+
for i in range(len(items)):
|
| 224 |
+
groups.setdefault(find(i), []).append(items[i])
|
| 225 |
+
tables = []
|
| 226 |
+
for g in groups.values():
|
| 227 |
+
H = [s for k, s in g if k == "h"]
|
| 228 |
+
V = [s for k, s in g if k == "v"]
|
| 229 |
+
ys = _cluster([(s[0] + s[1]) / 2 for s in H])
|
| 230 |
+
xs = _cluster([(s[0] + s[1]) / 2 for s in V])
|
| 231 |
+
if len(ys) < 2 or len(xs) < 2 or (len(ys) == 2 and len(xs) == 2):
|
| 232 |
+
continue # a plain box or single rule is not a table
|
| 233 |
+
tables.append({"ys": ys, "xs": xs, "H": H, "V": V,
|
| 234 |
+
"bbox": [min(xs), min(ys), max(xs), max(ys)]})
|
| 235 |
+
return tables, h | v
|
| 236 |
+
|
| 237 |
+
|
| 238 |
+
def _cluster(vals, tol=6):
|
| 239 |
+
out = []
|
| 240 |
+
for v in sorted(vals):
|
| 241 |
+
if out and v - out[-1][-1] <= tol:
|
| 242 |
+
out[-1].append(v)
|
| 243 |
+
else:
|
| 244 |
+
out.append([v])
|
| 245 |
+
return [float(np.mean(c)) for c in out]
|
| 246 |
+
|
| 247 |
+
|
| 248 |
+
def _covered(segs, pos, a, b, axis_pos, tol=4):
|
| 249 |
+
"""Fraction of [a,b] covered by rules lying at `pos` (rules given as (p0,p1,s,e))."""
|
| 250 |
+
cov = np.zeros(int(b - a) + 1, bool)
|
| 251 |
+
for s in segs:
|
| 252 |
+
if s[0] - tol <= pos <= s[1] + tol:
|
| 253 |
+
lo, hi = max(a, s[2]), min(b, s[3])
|
| 254 |
+
if hi > lo:
|
| 255 |
+
cov[int(lo - a):int(hi - a)] = True
|
| 256 |
+
return cov.mean() if len(cov) else 0.0
|
| 257 |
+
|
| 258 |
+
|
| 259 |
+
# ---------- text lines ----------
|
| 260 |
+
|
| 261 |
+
def _trim(ink, y0, y1, x0, x1):
|
| 262 |
+
sub = ink[y0:y1, x0:x1]
|
| 263 |
+
rows, cols = np.flatnonzero(sub.any(1)), np.flatnonzero(sub.any(0))
|
| 264 |
+
if not len(rows):
|
| 265 |
+
return None
|
| 266 |
+
return y0 + rows[0], y0 + rows[-1] + 1, x0 + cols[0], x0 + cols[-1] + 1
|
| 267 |
+
|
| 268 |
+
|
| 269 |
+
def _split_tall(ink, box, th):
|
| 270 |
+
"""Split a band that holds several touching lines at deep minima of its ink profile."""
|
| 271 |
+
y0, y1, x0, x1 = box
|
| 272 |
+
if y1 - y0 < 1.7 * th:
|
| 273 |
+
return [box]
|
| 274 |
+
prof = ink[y0:y1, x0:x1].sum(1).astype(float)
|
| 275 |
+
lo, hi = int(0.6 * th), int(y1 - y0 - 0.6 * th)
|
| 276 |
+
if hi <= lo:
|
| 277 |
+
return [box]
|
| 278 |
+
k = lo + int(np.argmin(prof[lo:hi]))
|
| 279 |
+
if prof[k] > 0.15 * np.median(prof[prof > 0]):
|
| 280 |
+
return [box]
|
| 281 |
+
a, b = _trim(ink, y0, y0 + k, x0, x1), _trim(ink, y0 + k, y1, x0, x1)
|
| 282 |
+
return (_split_tall(ink, a, th) if a else []) + (_split_tall(ink, b, th) if b else [])
|
| 283 |
+
|
| 284 |
+
|
| 285 |
+
def xy_cut(ink, box, th, out):
|
| 286 |
+
"""Recursive XY-cut. Appends text-line boxes (y0,y1,x0,x1) to `out` in reading order.
|
| 287 |
+
|
| 288 |
+
Order of preference: unusually large row gaps (section breaks), then a column gutter
|
| 289 |
+
that runs through the whole region, then ordinary line gaps.
|
| 290 |
+
"""
|
| 291 |
+
t = _trim(ink, *box)
|
| 292 |
+
if t is None:
|
| 293 |
+
return
|
| 294 |
+
y0, y1, x0, x1 = t
|
| 295 |
+
sub = ink[y0:y1, x0:x1]
|
| 296 |
+
row_gaps = _runs(~sub.any(1))
|
| 297 |
+
if len(row_gaps) >= 2:
|
| 298 |
+
typical = float(np.median([b - a for a, b in row_gaps]))
|
| 299 |
+
big = [(a, b) for a, b in row_gaps if b - a >= max(0.8 * th, 1.6 * typical)]
|
| 300 |
+
if big and len(big) < len(row_gaps):
|
| 301 |
+
cuts = [y0] + [y0 + (a + b) // 2 for a, b in big] + [y1]
|
| 302 |
+
for a, b in zip(cuts, cuts[1:]):
|
| 303 |
+
xy_cut(ink, (a, b, x0, x1), th, out)
|
| 304 |
+
return
|
| 305 |
+
gutter = _column_gutter(sub, th)
|
| 306 |
+
if gutter:
|
| 307 |
+
a, b = gutter
|
| 308 |
+
xy_cut(ink, (y0, y1, x0, x0 + a), th, out)
|
| 309 |
+
xy_cut(ink, (y0, y1, x0 + b, x1), th, out)
|
| 310 |
+
return
|
| 311 |
+
if not row_gaps and y1 - y0 > 1.7 * th and len(_split_tall(ink, t, th)) > 1:
|
| 312 |
+
# Tall region with no blank row: something (a frame's curved end, an icon) spans every row.
|
| 313 |
+
# Cut it apart on blank columns, then the pieces can split into lines again.
|
| 314 |
+
cols = [(a, b) for a, b in _runs(~sub.any(0)) if b - a >= 0.5 * th]
|
| 315 |
+
if cols:
|
| 316 |
+
edges = [0] + [p for a, b in cols for p in (a, b)] + [sub.shape[1]]
|
| 317 |
+
for a, b in zip(edges[::2], edges[1::2]):
|
| 318 |
+
xy_cut(ink, (y0, y1, x0 + a, x0 + b), th, out)
|
| 319 |
+
return
|
| 320 |
+
if len(row_gaps) == 1 and row_gaps[0][1] - row_gaps[0][0] >= 0.8 * th:
|
| 321 |
+
a, b = row_gaps[0]
|
| 322 |
+
xy_cut(ink, (y0, y0 + (a + b) // 2, x0, x1), th, out)
|
| 323 |
+
xy_cut(ink, (y0 + (a + b) // 2, y1, x0, x1), th, out)
|
| 324 |
+
return
|
| 325 |
+
cuts = [0] + [(a + b) // 2 for a, b in row_gaps] + [sub.shape[0]]
|
| 326 |
+
for a, b in zip(cuts, cuts[1:]):
|
| 327 |
+
line = _trim(ink, y0 + a, y0 + b, x0, x1)
|
| 328 |
+
if line and line[1] - line[0] >= 3:
|
| 329 |
+
out.extend(_split_tall(ink, line, th))
|
| 330 |
+
|
| 331 |
+
|
| 332 |
+
def _column_gutter(sub, th):
|
| 333 |
+
"""A blank vertical strip through the whole region separating two blocks of running text."""
|
| 334 |
+
H, W = sub.shape
|
| 335 |
+
if H < 1.8 * th:
|
| 336 |
+
return None
|
| 337 |
+
gaps = [(a, b) for a, b in _runs(~sub.any(0)) if b - a >= 1.5 * th and a >= 0.2 * W and W - b >= 0.2 * W]
|
| 338 |
+
for a, b in sorted(gaps, key=lambda g: g[0] - g[1]):
|
| 339 |
+
left, right = sub[:, :a], sub[:, b:]
|
| 340 |
+
n = len(_runs(left.any(1))) + len(_runs(right.any(1)))
|
| 341 |
+
if n >= 4 and _is_text_column(left, th) and _is_text_column(right, th):
|
| 342 |
+
return a, b
|
| 343 |
+
return None
|
| 344 |
+
|
| 345 |
+
|
| 346 |
+
def _is_text_column(side, th):
|
| 347 |
+
"""Running text: several lines, wide enough for a phrase, and most lines span most of the width."""
|
| 348 |
+
W = side.shape[1]
|
| 349 |
+
if W < 10 * th:
|
| 350 |
+
return False
|
| 351 |
+
rows = [r for r in _runs(side.any(1)) if r[1] - r[0] >= 0.3 * th]
|
| 352 |
+
if not rows:
|
| 353 |
+
return False
|
| 354 |
+
fills = []
|
| 355 |
+
for a, b in rows:
|
| 356 |
+
cols = np.flatnonzero(side[a:b].any(0))
|
| 357 |
+
fills.append((cols[-1] - cols[0] + 1) / W)
|
| 358 |
+
if len(rows) <= 2: # a short column: its first line must run the full width (wrapped prose)
|
| 359 |
+
return fills[0] >= 0.85
|
| 360 |
+
return float(np.median(fills)) >= 0.7
|
| 361 |
+
|
| 362 |
+
|
| 363 |
+
def line_segments(ink, box, th, gap=1.4):
|
| 364 |
+
"""Split a text line on wide horizontal gaps (table cells, tab stops)."""
|
| 365 |
+
y0, y1, x0, x1 = box
|
| 366 |
+
cols = ink[y0:y1, x0:x1].any(0)
|
| 367 |
+
segs, start = [], None
|
| 368 |
+
gaps = [(a, b) for a, b in _runs(~cols) if b - a >= gap * th]
|
| 369 |
+
edges = [0] + [p for a, b in gaps for p in (a, b)] + [len(cols)]
|
| 370 |
+
for s, e in zip(edges[::2], edges[1::2]):
|
| 371 |
+
t = _trim(ink, y0, y1, x0 + s, x0 + e)
|
| 372 |
+
if t:
|
| 373 |
+
segs.append(t)
|
| 374 |
+
return segs or [box]
|
| 375 |
+
|
| 376 |
+
|
| 377 |
+
def crop(gray, box):
|
| 378 |
+
"""Gray crop with a little margin, resized to the recognizer's input height."""
|
| 379 |
+
y0, y1, x0, x1 = box
|
| 380 |
+
h = y1 - y0
|
| 381 |
+
py, px = max(2, int(0.15 * h)), max(3, int(0.3 * h))
|
| 382 |
+
a = gray[max(0, y0 - py):min(gray.shape[0], y1 + py), max(0, x0 - px):min(gray.shape[1], x1 + px)]
|
| 383 |
+
border = np.median(np.concatenate([a[0], a[-1], a[:, 0], a[:, -1]]))
|
| 384 |
+
if border < a.mean(): # light text on a dark fill: make it dark-on-light like the training lines
|
| 385 |
+
a, border = 255 - a, 255 - border
|
| 386 |
+
lo = np.percentile(a, 2)
|
| 387 |
+
if border - lo > 20: # stretch so the background is white and the strokes are dark
|
| 388 |
+
a = (a - lo) * (230.0 / (border - lo)) + 10
|
| 389 |
+
w = max(8, min(4000, round(a.shape[1] * HEIGHT / max(1, a.shape[0]))))
|
| 390 |
+
return np.asarray(Image.fromarray(np.clip(a, 0, 255).astype(np.uint8)).resize((w, HEIGHT), Image.BILINEAR))
|
| 391 |
+
|
| 392 |
+
|
| 393 |
+
# ---------- whole page ----------
|
| 394 |
+
|
| 395 |
+
def text_blocks(text_ink, th, read, figures=True):
|
| 396 |
+
"""Text mask -> grouped blocks. read(boxes) -> [(text, confidence)] supplies the words for each box."""
|
| 397 |
+
lines = []
|
| 398 |
+
xy_cut(text_ink, (0, text_ink.shape[0], 0, text_ink.shape[1]), th, lines)
|
| 399 |
+
lines = [ln for ln in lines if (ln[1] - ln[0]) >= 0.35 * th or (ln[3] - ln[2]) >= 0.35 * th]
|
| 400 |
+
if lines:
|
| 401 |
+
th = float(np.median([ln[1] - ln[0] for ln in lines]))
|
| 402 |
+
figs, text_lines = [], []
|
| 403 |
+
for ln in lines:
|
| 404 |
+
h, w = ln[1] - ln[0], ln[3] - ln[2]
|
| 405 |
+
if figures and h > 4 * th and (text_ink[ln[0]:ln[1], ln[2]:ln[3]].mean() > 0.35 or w < 2 * h):
|
| 406 |
+
figs.append({"kind": "figure", "box": ln})
|
| 407 |
+
else:
|
| 408 |
+
text_lines.append(ln)
|
| 409 |
+
segs = [line_segments(text_ink, ln, th) for ln in text_lines]
|
| 410 |
+
flat = [s for ss in segs for s in ss]
|
| 411 |
+
got = read(flat) if flat else []
|
| 412 |
+
blocks, k = [], 0
|
| 413 |
+
for ln, ss in zip(text_lines, segs):
|
| 414 |
+
blocks.append({"kind": "line", "box": ln, "segs": ss, "parts": got[k:k + len(ss)],
|
| 415 |
+
"xh": core_height(text_ink, ln)})
|
| 416 |
+
k += len(ss)
|
| 417 |
+
return blocks + figs, th
|
| 418 |
+
|
| 419 |
+
|
| 420 |
+
def core_height(ink, box):
|
| 421 |
+
"""Rows holding most of a line's ink (about the x-height): tracks font size better than the
|
| 422 |
+
full ink box, which depends on whether a line happens to contain ascenders and descenders."""
|
| 423 |
+
y0, y1, x0, x1 = box
|
| 424 |
+
prof = ink[y0:y1, x0:x1].sum(1)
|
| 425 |
+
return int((prof >= 0.4 * prof.max()).sum()) if prof.size and prof.max() > 0 else y1 - y0
|
| 426 |
+
|
| 427 |
+
|
| 428 |
+
def analyze(img, recognizer, deskew=True):
|
| 429 |
+
"""Image -> list of blocks: heading / paragraph / list_item / table / figure (bbox in original pixels)."""
|
| 430 |
+
gray, ink, scale, angle = prepare(img, deskew)
|
| 431 |
+
th = text_height(ink)
|
| 432 |
+
tables, rules = find_tables(ink, th)
|
| 433 |
+
H, W = ink.shape
|
| 434 |
+
frames = _open_1d(ink, max(40, W // 8), 1) | _open_1d(ink, max(40, H // 8), 0) # boxes, banners, page rules
|
| 435 |
+
clean = remove_frames(ink & ~rules & ~frames, th)
|
| 436 |
+
text_ink = clean.copy()
|
| 437 |
+
for t in tables: # table interiors are read cell by cell, not as running text
|
| 438 |
+
x0, y0, x1, y1 = (int(v) for v in t["bbox"])
|
| 439 |
+
text_ink[max(0, y0 - 2):y1 + 3, max(0, x0 - 2):x1 + 3] = False
|
| 440 |
+
|
| 441 |
+
def ocr(boxes):
|
| 442 |
+
return recognizer.read([crop(gray, b) for b in boxes])
|
| 443 |
+
th = min(th, text_height(text_ink)) # re-estimate without frames and tables
|
| 444 |
+
blocks, th = text_blocks(text_ink, th, ocr)
|
| 445 |
+
for t in tables:
|
| 446 |
+
blocks.append(read_grid(t, clean, th, ocr))
|
| 447 |
+
out = group(blocks, th)
|
| 448 |
+
for b in out:
|
| 449 |
+
y0, y1, x0, x1 = b.pop("box")
|
| 450 |
+
b["bbox"] = [round(x0 / scale), round(y0 / scale), round(x1 / scale), round(y1 / scale)]
|
| 451 |
+
meta = {"deskew_degrees": angle, "upscaled": scale, "text_height_px": round(th / scale, 1)}
|
| 452 |
+
return out, meta
|
| 453 |
+
|
| 454 |
+
|
| 455 |
+
def read_grid(t, clean, th, read):
|
| 456 |
+
nr, nc = len(t["ys"]) - 1, len(t["xs"]) - 1
|
| 457 |
+
cells, jobs = [], []
|
| 458 |
+
for r in range(nr):
|
| 459 |
+
for c in range(nc):
|
| 460 |
+
y0, y1 = int(t["ys"][r]) + 3, int(t["ys"][r + 1]) - 2
|
| 461 |
+
x0, x1 = int(t["xs"][c]) + 3, int(t["xs"][c + 1]) - 2
|
| 462 |
+
sub = []
|
| 463 |
+
if y1 > y0 and x1 > x0:
|
| 464 |
+
xy_cut(clean, (y0, y1, x0, x1), th, sub)
|
| 465 |
+
cells.append((r, c, len(jobs), len(sub)))
|
| 466 |
+
jobs += sub
|
| 467 |
+
got = read(jobs) if jobs else []
|
| 468 |
+
grid = [["" for _ in range(nc)] for _ in range(nr)]
|
| 469 |
+
for r, c, k, n in cells:
|
| 470 |
+
grid[r][c] = " ".join(g[0] for g in got[k:k + n] if g[0])
|
| 471 |
+
_apply_spans(t, grid)
|
| 472 |
+
confs = [g[1] for g in got if g[1] is not None]
|
| 473 |
+
x0, y0, x1, y1 = t["bbox"]
|
| 474 |
+
return {"kind": "table", "box": (int(y0), int(y1), int(x0), int(x1)), "rows": grid,
|
| 475 |
+
"confidence": round(float(np.mean(confs)), 4) if confs else None}
|
| 476 |
+
|
| 477 |
+
|
| 478 |
+
def _apply_spans(t, grid):
|
| 479 |
+
"""Cells whose dividing rule is missing are merged: the text stays in the first cell."""
|
| 480 |
+
ys, xs = t["ys"], t["xs"]
|
| 481 |
+
for r in range(len(grid)):
|
| 482 |
+
for c in range(1, len(grid[0])):
|
| 483 |
+
if _covered(t["V"], xs[c], ys[r] + 2, ys[r + 1] - 2, 0) < 0.5 and not grid[r][c]:
|
| 484 |
+
grid[r][c] = None # continuation of a merged cell
|
| 485 |
+
for r in range(1, len(grid)):
|
| 486 |
+
for c in range(len(grid[0])):
|
| 487 |
+
if _covered(t["H"], ys[r], xs[c] + 2, xs[c + 1] - 2, 1) < 0.5 and not grid[r][c]:
|
| 488 |
+
grid[r][c] = None
|
| 489 |
+
for row in grid:
|
| 490 |
+
for c, v in enumerate(row):
|
| 491 |
+
if v is None:
|
| 492 |
+
row[c] = ""
|
| 493 |
+
|
| 494 |
+
|
| 495 |
+
def group(blocks, th):
|
| 496 |
+
"""Lines -> headings, paragraphs, list items and borderless tables, keeping reading order.
|
| 497 |
+
|
| 498 |
+
Lines arrive in XY-cut order (columns already respected); tables and figures are
|
| 499 |
+
inserted before the first text block that starts below them.
|
| 500 |
+
"""
|
| 501 |
+
lines = [b for b in blocks if b["kind"] == "line"]
|
| 502 |
+
gaps = [b["box"][0] - a["box"][1] for a, b in zip(lines, lines[1:])]
|
| 503 |
+
gaps = [g for g in gaps if 0 <= g < 2.5 * th]
|
| 504 |
+
lead = float(np.median(gaps)) if gaps else 0.5 * th # usual space between lines of one paragraph
|
| 505 |
+
xh = float(np.median([b["xh"] for b in lines])) if lines else th
|
| 506 |
+
others = [b for b in blocks if b["kind"] != "line"]
|
| 507 |
+
out, i = [], 0
|
| 508 |
+
while i < len(lines):
|
| 509 |
+
ln = lines[i]
|
| 510 |
+
# Borderless table: a run of lines that each split into 2+ aligned segments.
|
| 511 |
+
j = i
|
| 512 |
+
while j < len(lines) and len(lines[j]["segs"]) >= 2:
|
| 513 |
+
j += 1
|
| 514 |
+
if j - i >= 2:
|
| 515 |
+
table = _borderless(lines[i:j])
|
| 516 |
+
if table:
|
| 517 |
+
out.append(table)
|
| 518 |
+
i = j
|
| 519 |
+
continue
|
| 520 |
+
for ln2 in lines[i:j]: # not aligned like a table: keep as text lines
|
| 521 |
+
ln2["segs"], ln2["parts"] = [ln2["box"]], [(" ".join(p[0] for p in ln2["parts"] if p[0]),
|
| 522 |
+
_mean([p[1] for p in ln2["parts"]]))]
|
| 523 |
+
# fall through and handle line i as text
|
| 524 |
+
text = " ".join(p[0] for p in ln["parts"] if p[0])
|
| 525 |
+
conf = [p[1] for p in ln["parts"]]
|
| 526 |
+
if not text or (len(text) <= 2 and (_mean(conf) or 1) < 0.6):
|
| 527 |
+
i += 1 # specks, frame corners, icons read as one or two unsure characters
|
| 528 |
+
continue
|
| 529 |
+
h = ln["box"][1] - ln["box"][0]
|
| 530 |
+
size = ln["xh"] / max(1.0, xh) # font size relative to body text
|
| 531 |
+
prev = out[-1] if out else None
|
| 532 |
+
gap = ln["box"][0] - prev["box"][1] if prev else 1e9
|
| 533 |
+
big = size >= 1.22 or (size >= 1.1 and h >= 1.3 * th)
|
| 534 |
+
if big and len(text) < 120:
|
| 535 |
+
level = 1 if size >= 1.7 else 2 if size >= 1.32 else 3
|
| 536 |
+
if prev and prev["type"] == "heading" and prev["level"] == level and 0 <= gap < 1.0 * h:
|
| 537 |
+
_extend(prev, ln, text, conf)
|
| 538 |
+
else:
|
| 539 |
+
out.append({"type": "heading", "level": level, "text": text, "box": ln["box"], "conf": conf})
|
| 540 |
+
elif LIST_RE.match(text):
|
| 541 |
+
out.append({"type": "list_item", "text": text, "box": ln["box"], "conf": conf})
|
| 542 |
+
elif (prev and prev["type"] in ("paragraph", "list_item") and -0.5 * th < gap < max(0.9 * th, 1.35 * lead)
|
| 543 |
+
and abs(ln["box"][2] - prev["box"][2]) < 2.5 * th and not big):
|
| 544 |
+
_extend(prev, ln, text, conf)
|
| 545 |
+
else:
|
| 546 |
+
out.append({"type": "paragraph", "text": text, "box": ln["box"], "conf": conf})
|
| 547 |
+
i += 1
|
| 548 |
+
for o in others:
|
| 549 |
+
if o["kind"] == "figure":
|
| 550 |
+
o = {"type": "figure", "box": o["box"], **({"text": o["text"]} if o.get("text") else {})}
|
| 551 |
+
else:
|
| 552 |
+
o = {"type": "table", "rows": o["rows"], "box": o["box"], "confidence": o["confidence"]}
|
| 553 |
+
k = next((n for n, b in enumerate(out) if b["box"][0] > o["box"][0] and b["box"][2] < o["box"][3]), len(out))
|
| 554 |
+
out.insert(k, o)
|
| 555 |
+
for b in out:
|
| 556 |
+
if "lines" in b:
|
| 557 |
+
b["text"] = _join_lines(b.pop("lines"))
|
| 558 |
+
if "conf" in b:
|
| 559 |
+
c = [v for v in b.pop("conf") if v is not None]
|
| 560 |
+
b["confidence"] = round(float(np.mean(c)), 4) if c else None
|
| 561 |
+
return out
|
| 562 |
+
|
| 563 |
+
|
| 564 |
+
def _extend(block, ln, text, conf):
|
| 565 |
+
y0, y1, x0, x1 = block["box"]
|
| 566 |
+
b = ln["box"]
|
| 567 |
+
block["box"] = (min(y0, b[0]), max(y1, b[1]), min(x0, b[2]), max(x1, b[3]))
|
| 568 |
+
block.setdefault("lines", [(block["text"], y1 - y0 and x1 - x0)]).append((text, b[3] - b[2]))
|
| 569 |
+
block["conf"] += conf
|
| 570 |
+
|
| 571 |
+
|
| 572 |
+
def _join_lines(lines):
|
| 573 |
+
"""Join wrapped lines with spaces but keep real line breaks (code, addresses, poems).
|
| 574 |
+
|
| 575 |
+
A wrapped line ends only because the next word did not fit; if the next line's first
|
| 576 |
+
word would have fitted after this line, the break was deliberate.
|
| 577 |
+
"""
|
| 578 |
+
widest = max(w for _, w in lines)
|
| 579 |
+
out = lines[0][0]
|
| 580 |
+
for (prev, w), (text, _) in zip(lines, lines[1:]):
|
| 581 |
+
per_char = w / max(1, len(prev))
|
| 582 |
+
first = text.split()[0] if text.split() else ""
|
| 583 |
+
if w + per_char * (len(first) + 1) < 0.97 * widest:
|
| 584 |
+
out += "\n" + text
|
| 585 |
+
elif prev[-2:-1].islower() and prev.endswith("-") and text[:1].islower():
|
| 586 |
+
out = out[:-1] + text # rejoin a hyphenated word
|
| 587 |
+
else:
|
| 588 |
+
out += " " + text
|
| 589 |
+
return out
|
| 590 |
+
|
| 591 |
+
|
| 592 |
+
def _mean(vals):
|
| 593 |
+
vals = [v for v in vals if v is not None]
|
| 594 |
+
return round(float(np.mean(vals)), 4) if vals else None
|
| 595 |
+
|
| 596 |
+
|
| 597 |
+
def _borderless(lines):
|
| 598 |
+
"""Rows of aligned segments -> a table, or None when the segments do not line up in columns."""
|
| 599 |
+
x0 = min(ln["box"][2] for ln in lines)
|
| 600 |
+
x1 = max(ln["box"][3] for ln in lines)
|
| 601 |
+
occupied = np.zeros(x1 - x0 + 1, bool)
|
| 602 |
+
for ln in lines:
|
| 603 |
+
for s in ln["segs"]:
|
| 604 |
+
occupied[s[2] - x0:s[3] - x0] = True
|
| 605 |
+
spans = _runs(occupied)
|
| 606 |
+
if len(spans) < 2:
|
| 607 |
+
return None
|
| 608 |
+
rows, conf, fill = [], [], []
|
| 609 |
+
for ln in lines:
|
| 610 |
+
row, hit = [""] * len(spans), [False] * len(spans)
|
| 611 |
+
for s, (t, c) in zip(ln["segs"], ln["parts"]):
|
| 612 |
+
mid = (s[2] + s[3]) / 2 - x0
|
| 613 |
+
k = next((n for n, (a, b) in enumerate(spans) if a <= mid <= b), len(spans) - 1)
|
| 614 |
+
row[k] = (row[k] + " " + t).strip()
|
| 615 |
+
hit[k] = True
|
| 616 |
+
conf.append(c)
|
| 617 |
+
fill.append(sum(hit) / len(spans))
|
| 618 |
+
rows.append(row)
|
| 619 |
+
if np.mean(fill) < 0.6: # e.g. code with alignment spaces: many sparse "columns"
|
| 620 |
+
return None
|
| 621 |
+
box = (min(ln["box"][0] for ln in lines), max(ln["box"][1] for ln in lines), x0, x1)
|
| 622 |
+
return {"type": "table", "rows": rows, "box": box, "conf": conf}
|
docparse/ocr/render.py
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Synthetic text-line images for training and testing the OCR network.
|
| 2 |
+
|
| 3 |
+
Every line is drawn with a real font, then degraded the way scans, photos and
|
| 4 |
+
screenshots degrade text. Fonts and text are split: the test side uses fonts and
|
| 5 |
+
sentences that never appear in training (Carlito/Caladea are metric clones of
|
| 6 |
+
Calibri/Cambria, the default Office fonts).
|
| 7 |
+
"""
|
| 8 |
+
import io
|
| 9 |
+
import random
|
| 10 |
+
from functools import lru_cache
|
| 11 |
+
from pathlib import Path
|
| 12 |
+
|
| 13 |
+
import numpy as np
|
| 14 |
+
from PIL import Image, ImageDraw, ImageFilter, ImageFont
|
| 15 |
+
|
| 16 |
+
from .charset import CHARS, normalize
|
| 17 |
+
|
| 18 |
+
HEIGHT = 32
|
| 19 |
+
MAX_WIDTH = 1200
|
| 20 |
+
DATA = Path(__file__).resolve().parents[1] / "data"
|
| 21 |
+
FONT_DIR = Path("/usr/share/fonts")
|
| 22 |
+
|
| 23 |
+
TRAIN_FONTS = [
|
| 24 |
+
"truetype/dejavu/DejaVuSans.ttf", "truetype/dejavu/DejaVuSans-Bold.ttf", "truetype/dejavu/DejaVuSerif.ttf",
|
| 25 |
+
"truetype/dejavu/DejaVuSerif-Bold.ttf", "truetype/dejavu/DejaVuSansMono.ttf",
|
| 26 |
+
"truetype/liberation/LiberationSans-Regular.ttf", "truetype/liberation/LiberationSans-Bold.ttf",
|
| 27 |
+
"truetype/liberation/LiberationSans-Italic.ttf", "truetype/liberation/LiberationSerif-Regular.ttf",
|
| 28 |
+
"truetype/liberation/LiberationSerif-Bold.ttf", "truetype/liberation/LiberationSerif-Italic.ttf",
|
| 29 |
+
"truetype/liberation/LiberationMono-Regular.ttf", "truetype/freefont/FreeSans.ttf",
|
| 30 |
+
"truetype/freefont/FreeSansBold.ttf", "truetype/freefont/FreeSansOblique.ttf", "truetype/freefont/FreeSerif.ttf",
|
| 31 |
+
"truetype/freefont/FreeSerifBold.ttf", "truetype/freefont/FreeSerifItalic.ttf", "truetype/freefont/FreeMono.ttf",
|
| 32 |
+
"opentype/inter/Inter-Regular.otf", "opentype/inter/Inter-Bold.otf", "opentype/inter/Inter-Light.otf",
|
| 33 |
+
"opentype/inter/Inter-Medium.otf", "opentype/inter/Inter-Italic.otf", "opentype/inter/Inter-SemiBold.otf",
|
| 34 |
+
"truetype/jetbrains-mono/JetBrainsMono-Regular.ttf", "truetype/jetbrains-mono/JetBrainsMono-Bold.ttf",
|
| 35 |
+
]
|
| 36 |
+
TEST_FONTS = [
|
| 37 |
+
"truetype/crosextra/Carlito-Regular.ttf", "truetype/crosextra/Carlito-Bold.ttf",
|
| 38 |
+
"truetype/crosextra/Carlito-Italic.ttf", "truetype/crosextra/Caladea-Regular.ttf",
|
| 39 |
+
"truetype/crosextra/Caladea-Bold.ttf", "truetype/crosextra/Caladea-Italic.ttf",
|
| 40 |
+
]
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def available(fonts):
|
| 44 |
+
return [str(FONT_DIR / f) for f in fonts if (FONT_DIR / f).exists()]
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
@lru_cache(maxsize=4096)
|
| 48 |
+
def _font(path, size):
|
| 49 |
+
return ImageFont.truetype(path, size)
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
@lru_cache(maxsize=2)
|
| 53 |
+
def corpus(split):
|
| 54 |
+
lines = (DATA / f"corpus_{split}.txt").read_text(encoding="utf-8").splitlines()
|
| 55 |
+
return [ln for ln in lines if ln.strip()]
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
# ---------- text sampling ----------
|
| 59 |
+
|
| 60 |
+
MONTHS = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October",
|
| 61 |
+
"November", "December"]
|
| 62 |
+
WORDS = ["Invoice", "Total", "Subtotal", "Tax", "Amount", "Date", "Name", "Address", "Phone", "Email", "Qty",
|
| 63 |
+
"Price", "Description", "Item", "Balance", "Due", "Account", "Reference", "Page", "Table", "Figure",
|
| 64 |
+
"Summary", "Introduction", "Results", "Method", "Revenue", "Q1", "Q2", "Q3", "Q4", "Report", "ID",
|
| 65 |
+
"Status", "Approved", "Pending", "Customer", "Order", "Units", "Region", "North", "South", "Notes"]
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def _number(r):
|
| 69 |
+
k = r.random()
|
| 70 |
+
if k < 0.3:
|
| 71 |
+
return f"{r.randint(0, 999999):,}"
|
| 72 |
+
if k < 0.55:
|
| 73 |
+
return f"{r.uniform(-9999, 99999):,.2f}"
|
| 74 |
+
if k < 0.7:
|
| 75 |
+
return f"{r.uniform(0, 100):.1f}%"
|
| 76 |
+
if k < 0.85:
|
| 77 |
+
return r.choice("$€£") + f"{r.uniform(0, 50000):,.2f}"
|
| 78 |
+
return str(r.randint(0, 10 ** r.randint(1, 9)))
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def _structured(r):
|
| 82 |
+
k = r.randint(0, 11)
|
| 83 |
+
if k == 0:
|
| 84 |
+
return _number(r)
|
| 85 |
+
if k == 1:
|
| 86 |
+
y, m, d = r.randint(1990, 2030), r.randint(1, 12), r.randint(1, 28)
|
| 87 |
+
return r.choice([f"{y}-{m:02d}-{d:02d}", f"{d:02d}/{m:02d}/{y}", f"{MONTHS[m - 1]} {d}, {y}",
|
| 88 |
+
f"{d} {MONTHS[m - 1][:3]} {y}"])
|
| 89 |
+
if k == 2:
|
| 90 |
+
user = "".join(r.choice("abcdefghijklmnopqrstuvwxyz._0123456789") for _ in range(r.randint(3, 12)))
|
| 91 |
+
return f"{user}@{r.choice(['gmail.com', 'example.org', 'mail.co.uk', 'corp.io'])}"
|
| 92 |
+
if k == 3:
|
| 93 |
+
path = "/".join("".join(r.choice("abcdefghijklmnopqrstuvwxyz-") for _ in range(r.randint(2, 8)))
|
| 94 |
+
for _ in range(r.randint(1, 3)))
|
| 95 |
+
return f"https://www.{r.choice(['example', 'docs', 'data'])}.com/{path}"
|
| 96 |
+
if k == 4:
|
| 97 |
+
return f"{r.choice(['INV', 'PO', 'ID', 'REF', 'SKU', 'No.'])}-{r.randint(0, 99999):05d}"
|
| 98 |
+
if k == 5:
|
| 99 |
+
return f"+{r.randint(1, 99)} {r.randint(100, 999)} {r.randint(100, 999)} {r.randint(1000, 9999)}"
|
| 100 |
+
if k == 6:
|
| 101 |
+
return f"{r.choice(WORDS)}: {_number(r)}"
|
| 102 |
+
if k == 7:
|
| 103 |
+
return " ".join(_number(r) for _ in range(r.randint(2, 5)))
|
| 104 |
+
if k == 8:
|
| 105 |
+
return r.choice(["• ", "- ", "* ", f"{r.randint(1, 20)}. ", f"({r.choice('abcdef')}) "]) + _prose(r, 4, 40)
|
| 106 |
+
if k == 9:
|
| 107 |
+
return _prose(r, 3, 40).upper()
|
| 108 |
+
if k == 10:
|
| 109 |
+
return " ".join(w.capitalize() for w in _prose(r, 6, 45).split())
|
| 110 |
+
return "".join(r.choice(CHARS[:95]) for _ in range(r.randint(3, 25))).strip() or "x"
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
def _prose(r, lo, hi, split="train"):
|
| 114 |
+
para = r.choice(corpus(split))
|
| 115 |
+
n = r.randint(lo, hi)
|
| 116 |
+
if len(para) <= n:
|
| 117 |
+
return para
|
| 118 |
+
i = r.randint(0, len(para) - n)
|
| 119 |
+
j = para.rfind(" ", 0, i) + 1 # start on a word boundary
|
| 120 |
+
end = para.find(" ", j + n)
|
| 121 |
+
return para[j:end if end > 0 else None].strip() or para[:n]
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
def sample_text(r, split="train"):
|
| 125 |
+
k = r.random()
|
| 126 |
+
if k < 0.62:
|
| 127 |
+
t = _prose(r, 3, 70, split)
|
| 128 |
+
elif k < 0.95:
|
| 129 |
+
t = _structured(r)
|
| 130 |
+
else:
|
| 131 |
+
t = r.choice(WORDS)
|
| 132 |
+
return normalize(t).strip() or "a"
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
# ---------- rendering ----------
|
| 136 |
+
|
| 137 |
+
def render_line(text, font_path, size, r, degrade=True):
|
| 138 |
+
"""Draw one line of text and return a uint8 grayscale array of height HEIGHT."""
|
| 139 |
+
font = _font(font_path, size)
|
| 140 |
+
ink = r.randint(0, 70)
|
| 141 |
+
bg = r.randint(200, 255)
|
| 142 |
+
invert = degrade and r.random() < 0.04
|
| 143 |
+
if invert:
|
| 144 |
+
ink, bg = r.randint(225, 255), r.randint(20, 90)
|
| 145 |
+
left, top, right, bottom = font.getbbox(text)
|
| 146 |
+
asc, desc = font.getmetrics()
|
| 147 |
+
pad = int(size * 0.6) + 8
|
| 148 |
+
w, h = right - left + 2 * pad, asc + desc + 2 * pad
|
| 149 |
+
img = Image.new("L", (w, h), bg)
|
| 150 |
+
d = ImageDraw.Draw(img)
|
| 151 |
+
d.text((pad - left, pad), text, font=font, fill=ink)
|
| 152 |
+
if degrade and r.random() < 0.06: # underline
|
| 153 |
+
y = pad + asc + max(1, desc // 3)
|
| 154 |
+
d.line([pad, y, w - pad, y], fill=ink, width=max(1, size // 16))
|
| 155 |
+
if degrade and r.random() < 0.5:
|
| 156 |
+
img = img.rotate(r.uniform(-1.2, 1.2), resample=Image.BILINEAR, fillcolor=bg, expand=False)
|
| 157 |
+
|
| 158 |
+
# Crop: either the tight ink box or the font's full line box, plus random padding.
|
| 159 |
+
if r.random() < 0.5:
|
| 160 |
+
y0, y1 = pad + top, pad + bottom
|
| 161 |
+
else:
|
| 162 |
+
y0, y1 = pad, pad + asc + desc
|
| 163 |
+
x0, x1 = pad, pad + right - left
|
| 164 |
+
ph = (y1 - y0)
|
| 165 |
+
py0, py1 = (r.uniform(0.02, 0.25) * ph, r.uniform(0.02, 0.25) * ph) if degrade else (0.1 * ph, 0.1 * ph)
|
| 166 |
+
px0, px1 = (r.uniform(0.0, 0.6) * ph, r.uniform(0.0, 0.6) * ph) if degrade else (0.2 * ph, 0.2 * ph)
|
| 167 |
+
img = img.crop((int(x0 - px0), int(y0 - py0), int(x1 + px1) + 1, int(y1 + py1) + 1))
|
| 168 |
+
|
| 169 |
+
if degrade:
|
| 170 |
+
dr = ImageDraw.Draw(img)
|
| 171 |
+
if r.random() < 0.08: # remnants of table rules at the crop edge
|
| 172 |
+
side = r.randint(0, 3)
|
| 173 |
+
W, H = img.size
|
| 174 |
+
lw = r.randint(1, 2)
|
| 175 |
+
[lambda: dr.line([0, 0, W, 0], fill=ink, width=lw), lambda: dr.line([0, H - 1, W, H - 1], fill=ink, width=lw),
|
| 176 |
+
lambda: dr.line([0, 0, 0, H], fill=ink, width=lw), lambda: dr.line([W - 1, 0, W - 1, H], fill=ink, width=lw)][side]()
|
| 177 |
+
if r.random() < 0.35:
|
| 178 |
+
scale = r.uniform(0.45, 0.85) # resolution loss: low-dpi scans, small screenshots
|
| 179 |
+
small = img.resize((max(4, int(img.width * scale)), max(4, int(img.height * scale))), Image.BILINEAR)
|
| 180 |
+
img = small.resize(img.size, Image.BILINEAR)
|
| 181 |
+
elif r.random() < 0.3:
|
| 182 |
+
img = img.filter(ImageFilter.GaussianBlur(r.uniform(0.3, 0.9)))
|
| 183 |
+
out_w = max(8, min(MAX_WIDTH, round(img.width * HEIGHT / img.height)))
|
| 184 |
+
img = img.resize((out_w, HEIGHT), Image.BILINEAR)
|
| 185 |
+
a = np.asarray(img, dtype=np.float32)
|
| 186 |
+
if degrade:
|
| 187 |
+
if r.random() < 0.4:
|
| 188 |
+
a = a + np.random.default_rng(r.randint(0, 2 ** 31)).normal(0, r.uniform(2, 14), a.shape)
|
| 189 |
+
if r.random() < 0.15: # uneven lighting
|
| 190 |
+
a = a * np.linspace(r.uniform(0.75, 1.0), r.uniform(0.75, 1.0), a.shape[1])[None, :]
|
| 191 |
+
a = np.clip(a, 0, 255).astype(np.uint8)
|
| 192 |
+
if degrade and r.random() < 0.25:
|
| 193 |
+
buf = io.BytesIO()
|
| 194 |
+
Image.fromarray(a).save(buf, "JPEG", quality=r.randint(25, 85))
|
| 195 |
+
a = np.asarray(Image.open(buf).convert("L"))
|
| 196 |
+
return a
|
| 197 |
+
|
| 198 |
+
|
| 199 |
+
def sample(seed, split="train", degrade=True):
|
| 200 |
+
"""One (image, text) pair. split chooses both the font list and the corpus."""
|
| 201 |
+
r = random.Random(seed)
|
| 202 |
+
fonts = available(TRAIN_FONTS if split == "train" else TEST_FONTS)
|
| 203 |
+
text = sample_text(r, split)
|
| 204 |
+
size = r.randint(14, 44)
|
| 205 |
+
return render_line(text, r.choice(fonts), size, r, degrade), text
|
| 206 |
+
|
| 207 |
+
|
| 208 |
+
def dataset(count, seed, split="train", degrade=True):
|
| 209 |
+
images, texts = [], []
|
| 210 |
+
for i in range(count):
|
| 211 |
+
a, t = sample(seed * 1_000_003 + i, split, degrade)
|
| 212 |
+
images.append(a)
|
| 213 |
+
texts.append(t)
|
| 214 |
+
return images, texts
|
docparse/ocr/train.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Train the line recognizer from scratch on freshly rendered synthetic lines.
|
| 2 |
+
|
| 3 |
+
Each round renders a new set of lines (new seeds), so the network never sees the
|
| 4 |
+
same image twice. A checkpoint is written after every round; --resume continues.
|
| 5 |
+
"""
|
| 6 |
+
import json
|
| 7 |
+
import time
|
| 8 |
+
from multiprocessing import Pool
|
| 9 |
+
from pathlib import Path
|
| 10 |
+
|
| 11 |
+
import numpy as np
|
| 12 |
+
import torch
|
| 13 |
+
import torch.nn.functional as F
|
| 14 |
+
|
| 15 |
+
from .charset import encode
|
| 16 |
+
from .metrics import summary
|
| 17 |
+
from .model import LineOCR, Recognizer, batch_tensor
|
| 18 |
+
from .render import dataset
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def _make(args):
|
| 22 |
+
count, seed, split, degrade = args
|
| 23 |
+
return dataset(count, seed, split, degrade)
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def render(count, seed, split="train", degrade=True, workers=4):
|
| 27 |
+
chunk = 2000
|
| 28 |
+
jobs = [(min(chunk, count - i), seed * 10_007 + k, split, degrade) for k, i in enumerate(range(0, count, chunk))]
|
| 29 |
+
with Pool(workers) as pool:
|
| 30 |
+
parts = pool.map(_make, jobs)
|
| 31 |
+
return [a for p in parts for a in p[0]], [t for p in parts for t in p[1]]
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def batches(images, batch_size, rng):
|
| 35 |
+
"""Group lines of similar width (less padding), then shuffle the groups."""
|
| 36 |
+
order = rng.permutation(len(images))
|
| 37 |
+
groups = []
|
| 38 |
+
for s in range(0, len(order), batch_size * 64):
|
| 39 |
+
block = sorted(order[s:s + batch_size * 64], key=lambda i: images[i].shape[1])
|
| 40 |
+
groups += [block[k:k + batch_size] for k in range(0, len(block), batch_size)]
|
| 41 |
+
rng.shuffle(groups)
|
| 42 |
+
return [g for g in groups if len(g) == batch_size]
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def evaluate(net, images, texts):
|
| 46 |
+
net.eval()
|
| 47 |
+
rec = Recognizer.__new__(Recognizer)
|
| 48 |
+
rec.net, rec.device = net, "cpu"
|
| 49 |
+
preds = [t for t, _ in rec.read(images, batch=64)]
|
| 50 |
+
net.train()
|
| 51 |
+
return summary(preds, texts)
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def train(run, rounds=4, per_round=120_000, batch_size=48, lr=2e-3, seed=0, threads=4, resume=False, log=print):
|
| 55 |
+
run = Path(run)
|
| 56 |
+
run.mkdir(parents=True, exist_ok=True)
|
| 57 |
+
torch.manual_seed(seed)
|
| 58 |
+
torch.set_num_threads(threads)
|
| 59 |
+
net = LineOCR()
|
| 60 |
+
steps_per_round = per_round // batch_size
|
| 61 |
+
opt = torch.optim.AdamW(net.parameters(), lr=lr, weight_decay=1e-4)
|
| 62 |
+
sched = torch.optim.lr_scheduler.OneCycleLR(opt, max_lr=lr, total_steps=rounds * steps_per_round, pct_start=0.08)
|
| 63 |
+
start, hist = 0, []
|
| 64 |
+
ckpt = run / "last.pt"
|
| 65 |
+
if resume and ckpt.exists():
|
| 66 |
+
c = torch.load(ckpt, map_location="cpu", weights_only=False)
|
| 67 |
+
net.load_state_dict(c["state"])
|
| 68 |
+
opt.load_state_dict(c["opt"])
|
| 69 |
+
sched.load_state_dict(c["sched"])
|
| 70 |
+
start, hist = c["round"], c["history"]
|
| 71 |
+
log(f"resumed after round {start}")
|
| 72 |
+
val = render(3000, 900_001, "train") # training fonts and text, unseen seeds
|
| 73 |
+
test = render(3000, 900_002, "test") # held-out fonts and held-out text
|
| 74 |
+
t0 = time.time()
|
| 75 |
+
for rnd in range(start, rounds):
|
| 76 |
+
images, texts = render(per_round, seed * 100 + rnd + 1, "train")
|
| 77 |
+
labels = [torch.tensor(encode(t), dtype=torch.long) for t in texts]
|
| 78 |
+
rng = np.random.default_rng(seed * 100 + rnd)
|
| 79 |
+
net.train()
|
| 80 |
+
running = []
|
| 81 |
+
for step, idx in enumerate(batches(images, batch_size, rng)[:steps_per_round]):
|
| 82 |
+
x, lens = batch_tensor([images[i] for i in idx])
|
| 83 |
+
logp = net(x).transpose(0, 1) # (T,B,C)
|
| 84 |
+
tgt = [labels[i] for i in idx]
|
| 85 |
+
loss = F.ctc_loss(logp, torch.cat(tgt), torch.tensor(lens), torch.tensor([len(t) for t in tgt]),
|
| 86 |
+
blank=0, zero_infinity=True)
|
| 87 |
+
opt.zero_grad()
|
| 88 |
+
loss.backward()
|
| 89 |
+
torch.nn.utils.clip_grad_norm_(net.parameters(), 5.0)
|
| 90 |
+
opt.step()
|
| 91 |
+
sched.step()
|
| 92 |
+
running.append(loss.item())
|
| 93 |
+
if step % 250 == 0:
|
| 94 |
+
log(f"round {rnd + 1} step {step}/{steps_per_round} loss {np.mean(running[-250:]):.4f} "
|
| 95 |
+
f"lr {sched.get_last_lr()[0]:.2e} elapsed {time.time() - t0:.0f}s")
|
| 96 |
+
entry = {"round": rnd + 1, "train_loss": round(float(np.mean(running[-500:])), 4),
|
| 97 |
+
"val_train_fonts": evaluate(net, *val), "test_heldout_fonts": evaluate(net, *test),
|
| 98 |
+
"elapsed_s": round(time.time() - t0, 1)}
|
| 99 |
+
hist.append(entry)
|
| 100 |
+
log(json.dumps(entry))
|
| 101 |
+
torch.save({"state": net.state_dict(), "opt": opt.state_dict(), "sched": sched.state_dict(),
|
| 102 |
+
"round": rnd + 1, "history": hist}, ckpt)
|
| 103 |
+
del images, texts, labels
|
| 104 |
+
info = {"parameters": sum(p.numel() for p in net.parameters()), "rounds": rounds, "lines_per_round": per_round,
|
| 105 |
+
"batch_size": batch_size, "seed": seed, "history": hist}
|
| 106 |
+
torch.save({"config": net.config, "state": net.state_dict(), "info": info}, run / "ocr.pt")
|
| 107 |
+
(run / "train_info.json").write_text(json.dumps(info, indent=2))
|
| 108 |
+
return net, info
|
docparse/parsebench.py
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""ParseBench (LlamaIndex) integration: registers a `docparse` pipeline with parse-bench.
|
| 2 |
+
|
| 3 |
+
Usage (parse-bench needs Python 3.12+; install it next to docparse):
|
| 4 |
+
pip install "parse-bench[fast]"
|
| 5 |
+
python scripts/parsebench_docparse.py download
|
| 6 |
+
python scripts/parsebench_docparse.py run docparse # or: run docparse --test
|
| 7 |
+
|
| 8 |
+
Each page becomes Markdown (headings, lists, HTML tables) plus layout boxes labelled with
|
| 9 |
+
ParseBench's canonical classes, so all five dimensions can be scored.
|
| 10 |
+
"""
|
| 11 |
+
from datetime import datetime
|
| 12 |
+
from pathlib import Path
|
| 13 |
+
from typing import Any
|
| 14 |
+
|
| 15 |
+
from parse_bench.evaluation.layout_adapters.adapters import PyMuPDF4LLMLayoutAdapter
|
| 16 |
+
from parse_bench.extensions import register_layout_adapter, register_pipeline, register_provider
|
| 17 |
+
from parse_bench.inference.providers.base import Provider, ProviderPermanentError
|
| 18 |
+
from parse_bench.schemas.parse_output import LayoutItemIR, LayoutSegmentIR, PageIR, ParseLayoutPageIR, ParseOutput
|
| 19 |
+
from parse_bench.schemas.pipeline import PipelineSpec
|
| 20 |
+
from parse_bench.schemas.pipeline_io import InferenceRequest, InferenceResult, RawInferenceResult
|
| 21 |
+
from parse_bench.schemas.product import ProductType
|
| 22 |
+
|
| 23 |
+
from .extract import extract, plain, to_markdown
|
| 24 |
+
|
| 25 |
+
LABELS = {"paragraph": "Text", "list_item": "List-item", "table": "Table", "figure": "Picture",
|
| 26 |
+
"header": "Page-header", "footer": "Page-footer"}
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def _label(block):
|
| 30 |
+
if block["type"] == "heading":
|
| 31 |
+
return "Title" if block.get("level", 1) == 1 else "Section-header"
|
| 32 |
+
return LABELS.get(block["type"], "Text")
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def page_records(doc):
|
| 36 |
+
"""docparse document -> one record per page: body markdown, running header/footer, normalised boxes."""
|
| 37 |
+
sizes = doc.get("page_sizes") or []
|
| 38 |
+
pages = []
|
| 39 |
+
for n in range(1, max(1, doc["pages"]) + 1):
|
| 40 |
+
blocks = [b for b in doc["blocks"] if b.get("page", 1) == n]
|
| 41 |
+
boxes, parts, furniture = [], [], {"header": [], "footer": []}
|
| 42 |
+
number = ""
|
| 43 |
+
for b in blocks:
|
| 44 |
+
if b["type"] in furniture: # page furniture: its own page fields
|
| 45 |
+
furniture[b["type"]].append(b["text"])
|
| 46 |
+
if b.get("page_number") and not number:
|
| 47 |
+
number = plain(b["text"]).strip()
|
| 48 |
+
# Also kept in the body inside <page_header>/<page_footer> tags (the ParseBench convention).
|
| 49 |
+
md = f"<page_{b['type']}>{b['text']}</page_{b['type']}>"
|
| 50 |
+
else:
|
| 51 |
+
md = to_markdown({**doc, "blocks": [b], "pages": 1}, tables="html", page_marks=False).strip()
|
| 52 |
+
start = sum(len(p) + 2 for p in parts)
|
| 53 |
+
parts.append(md)
|
| 54 |
+
pos = [start, start + len(md)]
|
| 55 |
+
if b.get("bbox") and n <= len(sizes):
|
| 56 |
+
w, h = sizes[n - 1]
|
| 57 |
+
boxes.append({"class": _label(b), "bbox": list(b["bbox"]), "pos": pos, "text": md,
|
| 58 |
+
"width": w, "height": h})
|
| 59 |
+
w, h = sizes[n - 1] if n <= len(sizes) else (0.0, 0.0)
|
| 60 |
+
pages.append({"page_index": n - 1, "page_number": n, "text": "\n\n".join(parts), "width": w, "height": h,
|
| 61 |
+
"page_boxes": boxes, "header": "\n".join(furniture["header"]),
|
| 62 |
+
"footer": "\n".join(furniture["footer"]), "printed_page_number": number})
|
| 63 |
+
return pages
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
@register_provider("docparse")
|
| 67 |
+
class DocparseProvider(Provider):
|
| 68 |
+
"""Local, CPU-only: exact parsing of the PDF text layer; OCR for images and scanned pages."""
|
| 69 |
+
|
| 70 |
+
def run_inference(self, pipeline: PipelineSpec, request: InferenceRequest) -> RawInferenceResult:
|
| 71 |
+
if request.product_type != ProductType.PARSE:
|
| 72 |
+
raise ProviderPermanentError(f"docparse only supports PARSE, got {request.product_type}")
|
| 73 |
+
path = Path(request.source_file_path)
|
| 74 |
+
if not path.exists():
|
| 75 |
+
raise ProviderPermanentError(f"File not found: {path}")
|
| 76 |
+
started = datetime.now()
|
| 77 |
+
try:
|
| 78 |
+
doc = extract(path, ocr=self.base_config.get("ocr", "auto"))
|
| 79 |
+
except Exception as e: # report and keep the run going
|
| 80 |
+
raise ProviderPermanentError(f"docparse error: {type(e).__name__}: {e}") from e
|
| 81 |
+
done = datetime.now()
|
| 82 |
+
raw = {"pages": page_records(doc), "num_pages": doc["pages"], "warnings": doc["warnings"]}
|
| 83 |
+
return RawInferenceResult(request=request, pipeline=pipeline, pipeline_name=pipeline.pipeline_name,
|
| 84 |
+
product_type=request.product_type, raw_output=raw, started_at=started,
|
| 85 |
+
completed_at=done, latency_in_ms=int((done - started).total_seconds() * 1000))
|
| 86 |
+
|
| 87 |
+
def normalize(self, raw_result: RawInferenceResult) -> InferenceResult:
|
| 88 |
+
pages, layout = [], []
|
| 89 |
+
for p in raw_result.raw_output.get("pages", []):
|
| 90 |
+
md = p["text"]
|
| 91 |
+
pages.append(PageIR(page_index=p["page_index"], markdown=md))
|
| 92 |
+
if not (p["width"] and p["height"]):
|
| 93 |
+
continue
|
| 94 |
+
items = []
|
| 95 |
+
for box in p["page_boxes"]:
|
| 96 |
+
x0, y0, x1, y1 = box["bbox"]
|
| 97 |
+
W, H = box["width"], box["height"]
|
| 98 |
+
x0, x1 = max(0.0, min(x0, W)), max(0.0, min(x1, W))
|
| 99 |
+
y0, y1 = max(0.0, min(y0, H)), max(0.0, min(y1, H))
|
| 100 |
+
if x1 <= x0 or y1 <= y0:
|
| 101 |
+
continue
|
| 102 |
+
a, b = box["pos"] or (None, None)
|
| 103 |
+
content = md[a:b] if a is not None else box["text"]
|
| 104 |
+
seg = LayoutSegmentIR(x=x0 / W, y=y0 / H, w=(x1 - x0) / W, h=(y1 - y0) / H, label=box["class"],
|
| 105 |
+
start_index=a, end_index=b)
|
| 106 |
+
kind = {"Table": "table", "Picture": "image"}.get(box["class"], "text")
|
| 107 |
+
items.append(LayoutItemIR(type=kind, md=content, html=content if kind == "table" else "",
|
| 108 |
+
value=content, bbox=seg, layout_segments=[seg]))
|
| 109 |
+
layout.append(ParseLayoutPageIR(page_number=p["page_number"], width=p["width"], height=p["height"],
|
| 110 |
+
md=md, text=md, items=items, page_header_markdown=p.get("header", ""),
|
| 111 |
+
page_footer_markdown=p.get("footer", ""),
|
| 112 |
+
printed_page_number=p.get("printed_page_number", "")))
|
| 113 |
+
output = ParseOutput(task_type="parse", example_id=raw_result.request.example_id,
|
| 114 |
+
pipeline_name=raw_result.pipeline_name, pages=pages, layout_pages=layout,
|
| 115 |
+
markdown="\n\n".join(p.markdown for p in pages))
|
| 116 |
+
return InferenceResult(request=raw_result.request, pipeline_name=raw_result.pipeline_name,
|
| 117 |
+
product_type=raw_result.product_type, raw_output=raw_result.raw_output, output=output,
|
| 118 |
+
started_at=raw_result.started_at, completed_at=raw_result.completed_at,
|
| 119 |
+
latency_in_ms=raw_result.latency_in_ms)
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
@register_layout_adapter("docparse", priority=90)
|
| 123 |
+
class DocparseLayoutAdapter(PyMuPDF4LLMLayoutAdapter):
|
| 124 |
+
"""Same page-box layout as PyMuPDF4LLM; docparse already emits canonical labels."""
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
register_pipeline(PipelineSpec(pipeline_name="docparse", provider_name="docparse",
|
| 128 |
+
product_type=ProductType.PARSE, config={"ocr": "auto"}))
|
docparse/synth_pages.py
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Synthetic document pages with known content, for end-to-end OCR benchmarks.
|
| 2 |
+
|
| 3 |
+
A page has a title, section headings, wrapped paragraphs, a bullet list, a ruled table,
|
| 4 |
+
a borderless key/value table and sometimes a two-column section. "scan" pages are
|
| 5 |
+
rotated, blurred, noised and JPEG-compressed after drawing.
|
| 6 |
+
"""
|
| 7 |
+
import io
|
| 8 |
+
import random
|
| 9 |
+
|
| 10 |
+
import numpy as np
|
| 11 |
+
from PIL import Image, ImageDraw, ImageFilter
|
| 12 |
+
|
| 13 |
+
from .ocr.render import TEST_FONTS, TRAIN_FONTS, _font, _number, _prose, available, sample_text
|
| 14 |
+
|
| 15 |
+
PAGE = (1240, 1754) # A4 at 150 dpi
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def _family(fonts, r):
|
| 19 |
+
reg = [f for f in fonts if not any(k in f for k in ("Bold", "Italic", "Oblique", "Light", "Medium", "Semi"))]
|
| 20 |
+
base = r.choice(reg)
|
| 21 |
+
bold = next((f for f in fonts if f != base and f.startswith(base.split("-")[0].rsplit(".", 1)[0]) and "Bold" in f
|
| 22 |
+
and "Italic" not in f), base)
|
| 23 |
+
return base, bold
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def _wrap(text, font, width):
|
| 27 |
+
lines, cur = [], ""
|
| 28 |
+
for w in text.split():
|
| 29 |
+
t = (cur + " " + w).strip()
|
| 30 |
+
if font.getlength(t) <= width or not cur:
|
| 31 |
+
cur = t
|
| 32 |
+
else:
|
| 33 |
+
lines.append(cur)
|
| 34 |
+
cur = w
|
| 35 |
+
return lines + ([cur] if cur else [])
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def make_page(seed, split="test", scan=False):
|
| 39 |
+
r = random.Random(seed)
|
| 40 |
+
fonts = available(TEST_FONTS if split == "test" else TRAIN_FONTS)
|
| 41 |
+
regular, bold = _family(fonts, r)
|
| 42 |
+
body = r.randint(20, 26)
|
| 43 |
+
f_body, f_h1, f_h2 = _font(regular, body), _font(bold, int(body * 1.9)), _font(bold, int(body * 1.4))
|
| 44 |
+
img = Image.new("L", PAGE, 255)
|
| 45 |
+
d = ImageDraw.Draw(img)
|
| 46 |
+
M = 100
|
| 47 |
+
y = M
|
| 48 |
+
W = PAGE[0] - 2 * M
|
| 49 |
+
truth = []
|
| 50 |
+
|
| 51 |
+
def text_block(kind, text, font, x, width, level=None):
|
| 52 |
+
nonlocal y
|
| 53 |
+
lines = _wrap(text, font, width)
|
| 54 |
+
lh = int(font.size * 1.35)
|
| 55 |
+
for ln in lines:
|
| 56 |
+
d.text((x, y), ln, font=font, fill=0)
|
| 57 |
+
y += lh
|
| 58 |
+
b = {"type": kind, "text": " ".join(lines)}
|
| 59 |
+
if level:
|
| 60 |
+
b["level"] = level
|
| 61 |
+
truth.append(b)
|
| 62 |
+
|
| 63 |
+
def para(n_lo=120, n_hi=320):
|
| 64 |
+
return _prose(r, n_lo, n_hi, split)
|
| 65 |
+
|
| 66 |
+
text_block("heading", " ".join(w.capitalize() for w in _prose(r, 15, 40, split).split()[:6]), f_h1, M, W, 1)
|
| 67 |
+
y += body
|
| 68 |
+
for section in range(r.randint(2, 3)):
|
| 69 |
+
if y > PAGE[1] - 400:
|
| 70 |
+
break
|
| 71 |
+
text_block("heading", f"{section + 1}. " + " ".join(_prose(r, 10, 30, split).split()[:4]).capitalize(), f_h2, M, W, 2)
|
| 72 |
+
y += body // 2
|
| 73 |
+
kind = r.choice(["para", "list", "table", "kv", "columns"])
|
| 74 |
+
if kind == "para" or section == 0:
|
| 75 |
+
text_block("paragraph", para(), f_body, M, W)
|
| 76 |
+
y += body
|
| 77 |
+
if kind == "list":
|
| 78 |
+
for _ in range(r.randint(2, 4)):
|
| 79 |
+
text_block("list_item", "• " + _prose(r, 20, 70, split), f_body, M + 20, W - 20)
|
| 80 |
+
y += body
|
| 81 |
+
elif kind == "table":
|
| 82 |
+
cols = r.randint(3, 4)
|
| 83 |
+
head = ["Item", "Region", "Units", "Amount", "Status", "Date"]
|
| 84 |
+
r.shuffle(head)
|
| 85 |
+
rows = [head[:cols]] + [[(sample_text(r, split).split() or ["x"])[0][:12] if c == 0 else _number(r)
|
| 86 |
+
for c in range(cols)] for _ in range(r.randint(3, 5))]
|
| 87 |
+
cw = [max(f_body.getlength(str(row[c])) for row in rows) + 30 for c in range(cols)]
|
| 88 |
+
rh = int(body * 1.9)
|
| 89 |
+
x0 = M
|
| 90 |
+
if y + rh * len(rows) > PAGE[1] - M:
|
| 91 |
+
continue
|
| 92 |
+
for i, row in enumerate(rows):
|
| 93 |
+
xx = x0
|
| 94 |
+
for c, v in enumerate(row):
|
| 95 |
+
d.text((xx + 15, y + (rh - body) // 2), str(v), font=_font(bold if i == 0 else regular, body), fill=0)
|
| 96 |
+
xx += cw[c]
|
| 97 |
+
d.line([x0, y, xx, y], fill=0, width=2)
|
| 98 |
+
y += rh
|
| 99 |
+
d.line([x0, y, x0 + sum(cw), y], fill=0, width=2)
|
| 100 |
+
xx = x0
|
| 101 |
+
for c in range(cols + 1):
|
| 102 |
+
d.line([xx, y - rh * len(rows), xx, y], fill=0, width=2)
|
| 103 |
+
xx += cw[c] if c < cols else 0
|
| 104 |
+
truth.append({"type": "table", "rows": [[str(v) for v in row] for row in rows], "ruled": True})
|
| 105 |
+
y += body * 2
|
| 106 |
+
elif kind == "kv":
|
| 107 |
+
keys = r.sample(["Invoice No.", "Date", "Customer", "Total", "Due date", "Reference", "Tax"], 3)
|
| 108 |
+
rows = [[k, sample_text(r, split)[:24]] for k in keys]
|
| 109 |
+
lh = int(body * 1.6)
|
| 110 |
+
for k, v in rows:
|
| 111 |
+
d.text((M, y), k, font=_font(bold, body), fill=0)
|
| 112 |
+
d.text((M + 320, y), v, font=f_body, fill=0)
|
| 113 |
+
y += lh
|
| 114 |
+
truth.append({"type": "table", "rows": rows, "ruled": False})
|
| 115 |
+
y += body
|
| 116 |
+
elif kind == "columns":
|
| 117 |
+
gap = 60
|
| 118 |
+
cw = (W - gap) // 2
|
| 119 |
+
top = y
|
| 120 |
+
text_block("paragraph", para(250, 400), f_body, M, cw)
|
| 121 |
+
left_bottom, y = y, top
|
| 122 |
+
text_block("paragraph", para(250, 400), f_body, M + cw + gap, cw)
|
| 123 |
+
y = max(y, left_bottom) + body
|
| 124 |
+
if scan:
|
| 125 |
+
img = _scan(img, r)
|
| 126 |
+
return img, truth
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
def _scan(img, r):
|
| 130 |
+
img = img.rotate(r.uniform(-2.0, 2.0), resample=Image.BICUBIC, fillcolor=255)
|
| 131 |
+
img = img.filter(ImageFilter.GaussianBlur(r.uniform(0.4, 1.0)))
|
| 132 |
+
a = np.asarray(img, np.float32)
|
| 133 |
+
a = a * r.uniform(0.85, 1.0) + r.uniform(0, 20)
|
| 134 |
+
a = a + np.random.default_rng(r.randint(0, 2 ** 31)).normal(0, r.uniform(4, 10), a.shape)
|
| 135 |
+
img = Image.fromarray(np.clip(a, 0, 255).astype(np.uint8))
|
| 136 |
+
buf = io.BytesIO()
|
| 137 |
+
img.save(buf, "JPEG", quality=r.randint(45, 80))
|
| 138 |
+
return Image.open(io.BytesIO(buf.getvalue())).convert("L")
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
def truth_text(truth):
|
| 142 |
+
out = []
|
| 143 |
+
for b in truth:
|
| 144 |
+
if b["type"] == "table":
|
| 145 |
+
out += [" ".join(row) for row in b["rows"]]
|
| 146 |
+
else:
|
| 147 |
+
out.append(b["text"])
|
| 148 |
+
return " ".join(out)
|
docparse/ui.html
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!doctype html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="utf-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
| 6 |
+
<title>docparse</title>
|
| 7 |
+
<style>
|
| 8 |
+
:root { --bg:#f6f5f2; --panel:#fff; --ink:#1d1d1f; --muted:#6b6b70; --line:#dedcd6; --accent:#2457c5; }
|
| 9 |
+
@media (prefers-color-scheme: dark) { :root { --bg:#151517; --panel:#1f1f22; --ink:#ececee; --muted:#9a9aa2; --line:#33333a; --accent:#7ea2ff; } }
|
| 10 |
+
* { box-sizing: border-box; }
|
| 11 |
+
body { margin:0; font:15px/1.5 system-ui, sans-serif; background:var(--bg); color:var(--ink); }
|
| 12 |
+
main { max-width:1100px; margin:0 auto; padding:24px 16px 48px; }
|
| 13 |
+
h1 { font-size:22px; margin:0 0 4px; }
|
| 14 |
+
p.sub { color:var(--muted); margin:0 0 20px; }
|
| 15 |
+
#drop { border:2px dashed var(--line); border-radius:12px; padding:28px; text-align:center; background:var(--panel); cursor:pointer; }
|
| 16 |
+
#drop.over { border-color:var(--accent); }
|
| 17 |
+
.opts { display:flex; gap:16px; flex-wrap:wrap; margin:12px 0; color:var(--muted); font-size:14px; }
|
| 18 |
+
.tabs { display:flex; gap:4px; margin-top:20px; border-bottom:1px solid var(--line); }
|
| 19 |
+
.tabs button { background:none; border:0; padding:8px 14px; color:var(--muted); font:inherit; cursor:pointer; border-bottom:2px solid transparent; }
|
| 20 |
+
.tabs button.on { color:var(--ink); border-color:var(--accent); }
|
| 21 |
+
pre { background:var(--panel); border:1px solid var(--line); border-top:0; margin:0; padding:16px; white-space:pre-wrap; word-break:break-word; max-height:70vh; overflow:auto; font-size:13px; }
|
| 22 |
+
#status { margin-top:12px; color:var(--muted); min-height:1.5em; }
|
| 23 |
+
table { border-collapse:collapse; width:100%; background:var(--panel); font-size:13px; }
|
| 24 |
+
td, th { border:1px solid var(--line); padding:6px 8px; text-align:left; vertical-align:top; }
|
| 25 |
+
#blocks { max-height:70vh; overflow:auto; }
|
| 26 |
+
</style>
|
| 27 |
+
</head>
|
| 28 |
+
<body>
|
| 29 |
+
<main>
|
| 30 |
+
<h1>docparse</h1>
|
| 31 |
+
<p class="sub">Drop a PDF, image, Word, Excel, CSV or PowerPoint file. Born-digital files are parsed exactly; images and scanned pages are read by the small OCR network.</p>
|
| 32 |
+
<label id="drop">Drop a file here or click to choose<input id="file" type="file" hidden></label>
|
| 33 |
+
<div class="opts">
|
| 34 |
+
<label>OCR <select id="ocr"><option value="auto">auto (scanned pages only)</option><option value="always">always</option><option value="never">never</option></select></label>
|
| 35 |
+
<label><input id="ocrimg" type="checkbox"> read text inside embedded pictures</label>
|
| 36 |
+
</div>
|
| 37 |
+
<div id="status"></div>
|
| 38 |
+
<div class="tabs"><button data-t="md" class="on">Markdown</button><button data-t="blocks">Blocks</button><button data-t="json">JSON</button></div>
|
| 39 |
+
<pre id="md"></pre><div id="blocks" hidden></div><pre id="json" hidden></pre>
|
| 40 |
+
</main>
|
| 41 |
+
<script>
|
| 42 |
+
const $ = s => document.querySelector(s);
|
| 43 |
+
const drop = $('#drop'), input = $('#file');
|
| 44 |
+
drop.addEventListener('dragover', e => { e.preventDefault(); drop.classList.add('over'); });
|
| 45 |
+
drop.addEventListener('dragleave', () => drop.classList.remove('over'));
|
| 46 |
+
drop.addEventListener('drop', e => { e.preventDefault(); drop.classList.remove('over'); if (e.dataTransfer.files[0]) send(e.dataTransfer.files[0]); });
|
| 47 |
+
input.addEventListener('change', () => input.files[0] && send(input.files[0]));
|
| 48 |
+
document.querySelectorAll('.tabs button').forEach(b => b.onclick = () => {
|
| 49 |
+
document.querySelectorAll('.tabs button').forEach(x => x.classList.toggle('on', x === b));
|
| 50 |
+
['md', 'blocks', 'json'].forEach(id => $('#' + id).hidden = id !== b.dataset.t);
|
| 51 |
+
});
|
| 52 |
+
function esc(s) { return String(s).replace(/[&<>]/g, c => ({'&':'&','<':'<','>':'>'}[c])); }
|
| 53 |
+
async function send(file) {
|
| 54 |
+
$('#status').textContent = `Reading ${file.name} ...`;
|
| 55 |
+
const t0 = performance.now();
|
| 56 |
+
const r = await fetch('/api/extract', { method: 'POST', body: file, headers: {
|
| 57 |
+
'X-Filename': file.name, 'X-OCR': $('#ocr').value, 'X-OCR-Images': $('#ocrimg').checked ? '1' : '0' } });
|
| 58 |
+
const data = await r.json();
|
| 59 |
+
if (!r.ok) { $('#status').textContent = data.error; return; }
|
| 60 |
+
const d = data.document;
|
| 61 |
+
$('#status').textContent = `${d.blocks.length} blocks, ${d.pages} page(s)/sheet(s) in ${((performance.now() - t0) / 1000).toFixed(1)} s` +
|
| 62 |
+
(d.warnings.length ? ' · ' + d.warnings.join('; ') : '');
|
| 63 |
+
$('#md').textContent = data.markdown;
|
| 64 |
+
$('#json').textContent = JSON.stringify(d, null, 2);
|
| 65 |
+
const rows = d.blocks.map(b => `<tr><td>${b.page ?? b.sheet ?? ''}</td><td>${b.type}${b.level ? ' ' + b.level : ''}</td><td>${b.source ?? ''}</td>` +
|
| 66 |
+
`<td>${b.confidence ?? ''}</td><td>${b.type === 'table' ? esc(b.rows.map(r => r.join(' | ')).join('\n')).replace(/\n/g, '<br>') : esc(b.text ?? '')}</td></tr>`);
|
| 67 |
+
$('#blocks').innerHTML = `<table><tr><th>page</th><th>type</th><th>source</th><th>conf.</th><th>content</th></tr>${rows.join('')}</table>`;
|
| 68 |
+
}
|
| 69 |
+
</script>
|
| 70 |
+
</body>
|
| 71 |
+
</html>
|
docparse/ui.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Local browser UI: upload a document, see Markdown, JSON and the blocks (standard library HTTP server)."""
|
| 2 |
+
import json
|
| 3 |
+
import tempfile
|
| 4 |
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
from threading import Lock
|
| 7 |
+
|
| 8 |
+
PAGE = Path(__file__).with_name("ui.html")
|
| 9 |
+
MAX_BYTES = 40 * 2 ** 20
|
| 10 |
+
LOCK = Lock() # one extraction at a time keeps memory and CPU bounded
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def serve(port=7862, host="127.0.0.1", model=None):
|
| 14 |
+
from .extract import extract, to_markdown
|
| 15 |
+
|
| 16 |
+
class Handler(BaseHTTPRequestHandler):
|
| 17 |
+
def _send(self, code, body, ctype="application/json"):
|
| 18 |
+
data = body if isinstance(body, bytes) else body.encode()
|
| 19 |
+
self.send_response(code)
|
| 20 |
+
self.send_header("Content-Type", ctype)
|
| 21 |
+
self.send_header("Content-Length", str(len(data)))
|
| 22 |
+
self.end_headers()
|
| 23 |
+
self.wfile.write(data)
|
| 24 |
+
|
| 25 |
+
def do_GET(self):
|
| 26 |
+
if self.path in ("/", "/index.html"):
|
| 27 |
+
self._send(200, PAGE.read_bytes(), "text/html; charset=utf-8")
|
| 28 |
+
else:
|
| 29 |
+
self._send(404, json.dumps({"error": "not found"}))
|
| 30 |
+
|
| 31 |
+
def do_POST(self):
|
| 32 |
+
if not self.path.startswith("/api/extract"):
|
| 33 |
+
return self._send(404, json.dumps({"error": "not found"}))
|
| 34 |
+
n = int(self.headers.get("Content-Length") or 0)
|
| 35 |
+
if n <= 0 or n > MAX_BYTES:
|
| 36 |
+
return self._send(413, json.dumps({"error": f"file must be 1 byte to {MAX_BYTES // 2 ** 20} MB"}))
|
| 37 |
+
name = Path(self.headers.get("X-Filename", "upload.bin")).name
|
| 38 |
+
ocr = self.headers.get("X-OCR", "auto")
|
| 39 |
+
if ocr not in ("auto", "always", "never"):
|
| 40 |
+
ocr = "auto"
|
| 41 |
+
data = self.rfile.read(n)
|
| 42 |
+
try:
|
| 43 |
+
with LOCK, tempfile.TemporaryDirectory() as tmp:
|
| 44 |
+
path = Path(tmp) / name
|
| 45 |
+
path.write_bytes(data)
|
| 46 |
+
doc = extract(path, ocr=ocr, ocr_images=self.headers.get("X-OCR-Images") == "1", model=model)
|
| 47 |
+
self._send(200, json.dumps({"markdown": to_markdown(doc), "document": doc}, default=str))
|
| 48 |
+
except Exception as exc: # report parse errors to the page instead of dropping the connection
|
| 49 |
+
self._send(400, json.dumps({"error": f"{type(exc).__name__}: {exc}"}))
|
| 50 |
+
|
| 51 |
+
def log_message(self, *a):
|
| 52 |
+
pass
|
| 53 |
+
|
| 54 |
+
srv = ThreadingHTTPServer((host, port), Handler)
|
| 55 |
+
print(f"docparse UI on http://{host}:{port}", flush=True)
|
| 56 |
+
srv.serve_forever()
|
packages.txt
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
libreoffice-writer
|
| 2 |
+
libreoffice-calc
|
| 3 |
+
libreoffice-impress
|
requirements.txt
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
torch==2.8.0
|
| 2 |
+
numpy>=1.26,<3
|
| 3 |
+
pillow>=10,<12
|
| 4 |
+
pdfplumber>=0.11
|
| 5 |
+
pypdfium2>=4.30
|
| 6 |
+
python-docx>=1.1
|
| 7 |
+
openpyxl>=3.1
|