Kedhareswer commited on
Commit
c43fc2d
·
verified ·
1 Parent(s): b6fa86a

docparse upload UI

Browse files
.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: Docparse
3
- emoji: ⚡
4
- colorFrom: blue
5
- colorTo: blue
6
  sdk: gradio
7
- sdk_version: 6.30.0
8
- python_version: '3.12'
9
  app_file: app.py
10
  pinned: false
 
 
 
 
11
  ---
 
12
 
13
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 => ({'&':'&amp;','<':'&lt;','>':'&gt;'}[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