Document to JSON Extractor (Arabic and English)
Pricing
from $3.00 / 1,000 page processeds
Document to JSON Extractor (Arabic and English)
PDF, DOCX, image or plain text in, structured JSON out, with the page and paragraph anchor for every value so a person can verify it. Arabic and English: Hijri dates, Arabic-Indic digits, right to left order and Arabic clause numbering all handled. Pass a JSON Schema to get exactly your fields.
Pricing
from $3.00 / 1,000 page processeds
Rating
0.0
(0)
Developer
Omar Nagy
Maintained by CommunityActor stats
0
Bookmarked
1
Total users
0
Monthly active users
11 days ago
Last modified
Categories
Share
A PDF, DOCX, image or plain text file goes in. Structured JSON comes out, and every value carries the page and paragraph it was read from, so a person can check it.
What it does
- Reads the text layer of a PDF with pdf.js, the body of a DOCX straight from OOXML, and plain text as it stands.
- Reads images with OCR using Tesseract data that ships as pinned npm dependencies and is baked into the image at build time, so a run never waits on a language model CDN. English and Arabic.
- Anchors everything. Every paragraph gets a stable handle such as
P17. Every clause gets one such asC10. Every extracted value reports the paragraph anchor, the clause anchor and the page it came from, plus the verbatim sentence it was cut out of. - Treats Arabic as a first class citizen. Presentation form glyphs are mapped back to base letters, lines that a PDF stored in visual order are put back into logical order, Arabic-Indic digits are normalised before any number is parsed, Hijri dates are converted and the conversion is disclosed, and Arabic clause numbering conventions are segmented alongside the English ones.
- Extracts to your schema. Pass a JSON Schema and get back exactly its properties, no more and no fewer, each one anchored.
- Never invents a value. The engine is deterministic and rule based, with no language model in the path. A value is only ever returned together with a verbatim evidence span and the anchor that span came from. If a field cannot be anchored it comes back as
found: falsewith a null value.
Who it is for
An AI agent that has to read a document and then cite it. Contract review, invoice and receipt intake, bilingual Arabic and English legal or compliance files, RAG ingestion that needs paragraph level provenance rather than one undifferentiated blob of text.
The concrete use case this was built around: an agent is handed a services agreement, asked "when does this renew and how much notice do I have to give", and has to answer with a number and with the clause a human can open and read. mode: "extract" returns both in one call.
It also runs in Apify standby mode, which keeps a container warm and answers over HTTP, so an agent can convert a document mid task without paying a cold start.
Modes
| Mode | Standby route | What you get |
|---|---|---|
analyze | GET /analyze | The normalised text, every paragraph with its anchor and direction, the clause segmentation, the detected language mix, and any repairs or warnings. |
extract | GET or POST /extract | Exactly the fields your JSON Schema names, plus a report per field with the anchor, the evidence span, the method and a confidence. |
Any other mode is refused with a 400 that lists the allowed ones. Any other standby route is a 404. A caller mistake never comes back as a 500.
Input
Give exactly one document source. Naming none, or naming two, is refused.
| Field | Type | Notes |
|---|---|---|
mode | string | analyze or extract. Required. Defaults to analyze. |
sample | string | Run against a document bundled in the actor: english-services-agreement, arabic-services-agreement, english-invoice-scan. Use none when you supply your own document. |
documentUrl | string | Direct http or https link to the file. A key-value store record URL is an ordinary URL and works here. |
keyValueStoreRecordKey | string | Key of a record in this run's own default key-value store, for a file uploaded to the run. |
documentText | string | Paste the text directly. |
documentBase64 | string | The file itself, base64 encoded. |
fileName | string | Optional. Helps format detection when the source carries no name. The bytes are trusted before the name. |
schema | object | Required when mode is extract. Ignored when mode is analyze. |
ocr | boolean | Default true. When false, an image input is refused rather than answered with nothing. |
ocrLanguages | array | Default ["eng", "ara"]. Only those two are bundled, anything else is refused. Used only for image input. |
The extraction schema
A JSON Schema object. Property types supported: string, number, integer, boolean, and array of string. Nested objects are refused with a message rather than answered with nulls, so flatten them into separate fields.
Two optional extensions per property:
x-keywordsis a list of extra terms to look for, in either script. This is the single biggest lever on accuracy. Quote the phrase the document itself uses.x-patternis a regular expression. It runs against a fold of the text where Arabic-Indic digits and letter variants are normalised, and its first capture group becomes the value. The value is always cut from the original text, so it comes back exactly as the document writes it.
{"type": "object","properties": {"effective_date": { "type": "string", "format": "date", "description": "the date the agreement takes effect" },"annual_fee": { "type": "number", "description": "the fixed annual fee payable by the client" },"notice_period_days": {"type": "integer","description": "days of written notice required for non renewal","x-keywords": ["notice of non-renewal"]},"auto_renews": {"type": "boolean","description": "whether the agreement renews automatically","x-keywords": ["automatically renew"]}},"required": ["effective_date", "annual_fee"]}
Output
One record per document. The dataset from a run has no column that is null in every row: fields that belong only to one path, such as the OCR confidence columns, exist only on runs that took that path.
mode: "extract", real output
Input: mode: "extract", sample: "english-services-agreement", and the schema above. This is the actual record, abbreviated only where marked.
{"mode": "extract","sourceType": "sample","sourceRef": "english-services-agreement","fileName": "services-agreement-en.pdf","format": "pdf","textSource": "pdf-text-layer","byteSize": 71407,"pageCount": 5,"pagesCharged": 5,"ocrPagesCharged": 0,"paragraphCount": 67,"clauseCount": 50,"characterCount": 8225,"language": "latin","direction": "ltr","arabicShare": 0,"latinShare": 1,"arabicChars": 0,"latinChars": 6415,"normalisations": [],"warnings": [],"extractedAt": "2026-09-05T00:44:00.361Z","fields": {"effective_date": "2025-03-14","annual_fee": 240000,"notice_period_days": 60,"auto_renews": true},"fieldReports": [{"field": "effective_date","type": "string","required": true,"found": true,"value": "2025-03-14","valueRaw": "14 March 2025","evidence": "This Professional Services Agreement (the \"Agreement\") is made and entered into on 14 March 2025 (the \"Effective Date\")","anchors": [{ "paragraphAnchor": "P2", "clauseAnchor": "C1", "page": 1 }],"paragraphAnchor": "P2","clauseAnchor": "C1","page": 1,"matchedTerms": ["effective", "date", "agreement", "effect"],"method": "date-parse","confidence": 0.9,"notes": []},{"field": "notice_period_days","type": "integer","required": false,"found": true,"value": 60,"valueRaw": "60","evidence": "2.2 Upon expiry of the Initial Term, this Agreement shall automatically renew for successive periods of twelve (12) months each (each a \"Renewal Term\") unless either Party gives written notice of non- renewal to the other Party not less than sixty (60) days prior to the end of the then current term.","anchors": [{ "paragraphAnchor": "P18", "clauseAnchor": "C10", "page": 2 }],"paragraphAnchor": "P18","clauseAnchor": "C10","page": 2,"matchedTerms": ["notice", "period", "days", "notice of non-renewal", "written", "non", "renewal"],"method": "number-parse-nearest-term","confidence": 0.92,"notes": []}],"fieldsRequested": 4,"fieldsFound": 4,"missingRequired": [],"complete": true}
evidence is a verbatim substring of the paragraph named by paragraphAnchor. That is checked by the test suite, not just asserted here. Note the non- renewal in that evidence span: the source PDF wrapped a hyphenated word across a line, and the text is handed back with the artefact rather than tidied up. Keyword matching closes the gap internally, which is why notice of non-renewal still appears in matchedTerms.
mode: "analyze", real output from the Arabic sample
{"mode": "analyze","format": "docx","textSource": "docx-body","language": "arabic","direction": "rtl","arabicShare": 0.988,"latinShare": 0.012,"paragraphCount": 62,"clauseCount": 49,"characterCount": 6199,"paragraphs": [{ "anchor": "P2", "index": 2, "page": null, "text": "تحرر هذا العقد في مدينة القاهرة بتاريخ ١٥ رمضان ١٤٤٦هـ الموافق ١٤ مارس ٢٠٢٥م", "direction": "rtl", "charCount": 76 }],"clauses": [{"anchor": "C5","index": 5,"label": "المادة (١)","normalisedLabel": "1","numbering": "arabic-article","heading": "التمهيد وملحقات العقد","paragraphAnchors": ["P9", "P10"],"page": null,"direction": "rtl","parentAnchor": null,"depth": 0}]}
Running extract on that same Arabic document for its execution date returns "2025-03-15" with method: "hijri-date-parse" and this note attached to the value:
Converted with the tabular civil Hijri calendar. The sighting based calendar a court or a ministry applies can differ by a day, so treat this value as an approximation and check any Gregorian equivalent the document states for itself.
Field reference
| Field | Meaning |
|---|---|
format | pdf, docx, image or text, decided from the bytes first and the file name second. |
textSource | pdf-text-layer, docx-body, plain-text or ocr. Says how the text was obtained. |
pageCount | Pages, or null for a format that carries no pagination such as a DOCX with no page breaks. |
pagesCharged | Pages read from a text layer, charged as page. Always an integer, 0 on an OCR run. |
ocrPagesCharged | Pages read by OCR, charged as ocr-page. Always an integer, 0 on a text layer run. pagesCharged + ocrPagesCharged is the number of pages read, which equals pageCount when there is one and otherwise counts one page per 3000 characters. |
language / direction | arabic, latin, mixed or undetermined, measured by letter count with digits and punctuation ignored. |
normalisations | Repairs applied to the text, each one an encoding defect in the source rather than an editorial change. Empty when the source was clean. |
warnings | Things that should change how much you trust the output, for example a PDF whose font has a broken ToUnicode map. |
paragraphs[] | anchor, index, page, text, direction, charCount, and ocrConfidence on OCR runs only. |
clauses[] | anchor, label as the document writes it, normalisedLabel in ASCII, numbering, heading, text, paragraphAnchors, page, direction, parentAnchor, depth. |
fieldReports[].method | How the value was recovered: date-parse, hijri-date-parse, number-parse, number-parse-nearest-term, number-parse-monetary, enum-match, x-pattern, boolean-assertion, boolean-negation-cue, sentence-match or none. |
fieldReports[].confidence | A stated formula, not a feeling: it starts from how much of the field's own vocabulary the paragraph carried and is lifted when a typed parser recovered the value rather than a sentence fallback. Capped at 0.95, because a rule engine is never certain. |
Standby HTTP API
GET / service info and the route listGET /analyze?sample=english-services-agreementGET /analyze?documentUrl=https://example.com/contract.pdfPOST /extract {"documentUrl": "...", "schema": {...}}GET /extract?documentUrl=...&schema=<url encoded JSON>
Every response is { ok, tookMs, result } or { ok: false, error }. An unknown route is a 404 with the allowed routes named.
Pay-per-event
The actor charges pay-per-event with three named events, and there is no run start fee, so a call costs only the events it actually consumes. The current rates are on the Pricing tab of this listing. Every count is reported in the record itself, so a bill can be reconciled against the output line by line, and every charge is wrapped so a billing failure can never break a response that was already produced correctly.
| Event | Charged | Why it is a separate event |
|---|---|---|
page | Once per page read from a text layer, so a PDF, a DOCX or plain text. Reported in every record as pagesCharged. | Reading is the cost that scales with the document. |
ocr-page | Once per page read by OCR, so an image input. Reported in every record as ocrPagesCharged. | OCR costs about seven times what reading a text layer costs, measured on the platform. Charging one blended page price would overcharge the common case to subsidise the rare expensive one. |
extraction | Once per schema constrained extraction, so once per extract call. | Extraction is work done on top of reading, and an analyze call does not do it. |
A document is never part text layer and part OCR: only an image goes through OCR, because a scanned PDF is refused with a warning rather than rasterised. So exactly one of the two page events fires on any one document, and pagesCharged + ocrPagesCharged always equals the pages read.
Charging is explicit rather than relying on Apify's built in dataset item event, because standby mode answers over HTTP and never pushes to a dataset, so the built in event would charge nothing and the standby API would be served for free.
Limits
- One document per run, up to 25 MB. Standby mode is the way to do many documents quickly.
- A dataset item has a size ceiling. A very long document sheds its full text, then its clause list, then its trailing paragraphs, and says in
warningsexactly what it dropped rather than failing the push. - OCR covers English and Arabic only, because those are the two language models bundled. Arabic OCR is materially less accurate than the text layer path, especially on Arabic-Indic digits, and the record says so in
warningswhenever Arabic OCR runs. - A schema may name at most 40 fields.
- The actor runs with limited permissions. It reads this run's own default key-value store and makes HTTP requests, nothing else. To read a record in another store, pass its record URL as
documentUrl.
What it does not do
It does not summarise, interpret, translate, or give advice, and there is no language model anywhere in the path. It does not rasterise a scanned PDF, because that needs a native canvas build that cannot be installed in the Apify Node image, so a scanned PDF comes back with a warning telling you to send the page images through the image input instead. It does not guess: an unrecognised mode, an unknown sample, a nested schema object or a missing document source is refused with a 400 that says how to fix it, and a field it cannot anchor is reported as not found rather than filled in.
One honest limitation of the rule based approach: for string and array fields the engine returns the best matching sentence, ranked by how much of your field vocabulary it carried. On a document that mentions the same words in several places, it can land on a reference to a term rather than on the sentence that defines it. That is why every value ships with an anchor and an evidence span, and why x-keywords quoting the defining phrase is the fastest way to sharpen a result.
Local development
npm cinpm run smoke # 19 checks over the bundled samples, including the paths that must failnpm start # one batch run, reading storage/key_value_stores/default/INPUT.jsonnpm run start:standby # the HTTP API on :4321
The icon lives at .actor/logo.png. The bundled samples in samples/ use invented companies and invented people, and are not real agreements. They are stored base64 encoded as text, because Apify's source file upload sniffs each file as text or binary and read the small DOCX as text, which round tripped it through UTF-8 and delivered a corrupt zip to the built image. Decode one with base64 -d samples/services-agreement-en.pdf.b64 > contract.pdf.

