<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Kanto Labs]]></title><description><![CDATA[Kanto Labs]]></description><link>https://kanto-labs.hashnode.dev</link><image><url>https://cdn.hashnode.com/res/hashnode/image/upload/v1593680282896/kNC7E8IR4.png</url><title>Kanto Labs</title><link>https://kanto-labs.hashnode.dev</link></image><generator>RSS for Node</generator><lastBuildDate>Fri, 25 Sep 2026 22:43:31 GMT</lastBuildDate><atom:link href="https://kanto-labs.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[LTX-2.5 in ComfyUI on a 20 GB GPU: Draft → Learned x2 Upscale → Refine (What Actually Matters)]]></title><description><![CDATA[Short answer: on a 20 GB card, run LTX-2.5 as three stages, not one:

Draft at half the final resolution, 8 steps, distilled model at cfg 1.0.
Upscale the latent x2 with LTX's learned spatial upscaler]]></description><link>https://kanto-labs.hashnode.dev/ltx-2-5-in-comfyui-on-a-20-gb-gpu-draft-learned-x2-upscale-refine-what-actually-matters</link><guid isPermaLink="true">https://kanto-labs.hashnode.dev/ltx-2-5-in-comfyui-on-a-20-gb-gpu-draft-learned-x2-upscale-refine-what-actually-matters</guid><category><![CDATA[AI]]></category><category><![CDATA[generative ai]]></category><category><![CDATA[video]]></category><category><![CDATA[Tutorial]]></category><category><![CDATA[comfyui]]></category><dc:creator><![CDATA[Kanto Labs]]></dc:creator><pubDate>Wed, 23 Sep 2026 09:45:55 GMT</pubDate><content:encoded><![CDATA[<p><strong>Short answer:</strong> on a 20 GB card, run LTX-2.5 as three stages, not one:</p>
<ol>
<li><strong>Draft</strong> at half the final resolution, 8 steps, distilled model at cfg 1.0.</li>
<li><strong>Upscale</strong> the latent x2 with LTX's <strong>learned</strong> spatial upscaler (one pass, no sampling).</li>
<li><strong>Refine</strong> for 3 steps at full size.</li>
</ol>
<p>Then respect four rules: frame counts must be <strong>8n+1</strong>, stage-1 sides must be <strong>multiples of 32</strong>,
<strong>system RAM</strong> (not VRAM) is usually what kills long renders, and <strong>a widget with a wire plugged into
it is ignored</strong>. Everything below was measured on one machine: an RTX 3080 with 20 GB of VRAM and 32 GB
of system RAM, running ComfyUI 0.37.0. Where we didn't measure something, we say so.</p>
<h2>Why three stages instead of one</h2>
<p>The obvious way to get a 1280x720 clip is to ask for 1280x720 and let the sampler run. On a consumer
card that is the slow way, and past a certain size it is the failing way.</p>
<p>The two-stage recipe splits the work:</p>
<ul>
<li><strong>Stage 1 (draft)</strong> samples at half resolution, for example 640x352 for a 16:9 clip, with the
distilled model's 8-step sigma schedule. This is where motion, composition and the soundtrack are
decided, and it is cheap because the latent is a quarter of the area.</li>
<li><strong>The x2 upscale</strong> hands that latent to <code>LTXVLatentUpsampler</code> with the
<code>ltx-2.5-latent-spatial-upscaler-x2</code> model. This is a <strong>trained network</strong>, not bicubic resampling. It
runs as a single forward pass, so it costs a fraction of a sampling step.</li>
<li><strong>Stage 2 (refine)</strong> runs 3 more sampling steps at full size to put back the fine detail the upscaler
can't invent.</li>
</ul>
<p>Measured on our 3080: a <strong>5-second 1280x720 clip with audio, draft + upscale + refine, took 283.5 s</strong>
of wall time from a cold start (model load included). 10 s and 15 s clips at 1280x720 also completed.</p>
<p>A practical workflow follows from this. <strong>Render stage 1 on its own first.</strong> A stage-1-only draft at
640x352 and 121 frames came out as a 0.52 MB file, against 4.6-6.9 MB for full runs. It's enough to
judge whether a take is worth keeping. Draft small, pick the keeper, then run the full chain (or the
upscale alone) on that one.</p>
<h2>The size rules: 8n+1 frames and multiples of 32</h2>
<p>LTX-2.5 generates <strong>8n+1 frames</strong>. At 24 fps that gives:</p>
<table>
<thead>
<tr>
<th>seconds</th>
<th>frames</th>
</tr>
</thead>
<tbody><tr>
<td>3</td>
<td>73</td>
</tr>
<tr>
<td>5</td>
<td>121</td>
</tr>
<tr>
<td>8</td>
<td>193</td>
</tr>
<tr>
<td>10</td>
<td>241</td>
</tr>
<tr>
<td>15</td>
<td>361</td>
</tr>
<tr>
<td>20</td>
<td>481</td>
</tr>
</tbody></table>
<p>Ask for any other number and the model snaps it, and the audio length can drift away from the
picture. Pick from the table.</p>
<p>Sizes follow the same kind of rule. <strong>Stage-1 sides must be multiples of 32</strong>, and the final size is
<strong>exactly 2x stage 1</strong>:</p>
<table>
<thead>
<tr>
<th>stage 1</th>
<th>final</th>
<th>shape</th>
</tr>
</thead>
<tbody><tr>
<td>352x640</td>
<td>704x1280</td>
<td>9:16, ~0.9 MP</td>
</tr>
<tr>
<td>416x736</td>
<td>832x1472</td>
<td>9:16, ~1.2 MP</td>
</tr>
<tr>
<td>640x352</td>
<td>1280x704</td>
<td>16:9, ~0.9 MP</td>
</tr>
<tr>
<td>736x416</td>
<td>1472x832</td>
<td>16:9, ~1.2 MP</td>
</tr>
</tbody></table>
<p>Note the 16:9 row: 720 is not a multiple of 64, so "720p" through a x2 upscaler lands on 1280x704.</p>
<h3>Where the ceiling is on 20 GB</h3>
<p><strong>About 1.2 MP final is the working ceiling</strong> on our card (416x736 → 832x1472). We tried 2.0 MP
(544x960 stage 1). ComfyUI spilled <strong>9.5 GB to system RAM</strong>, each step took <strong>about 58 s (roughly 6x
slower)</strong>, and then ComfyUI <strong>crashed on the next model load</strong>.</p>
<p>The warning signs appear in the console before the crash. Look for
<code>loaded partially ... MB offloaded</code> together with a jump in s/it. That means the model no longer fits
and is being streamed. If you see it, lower the resolution before you touch anything else.</p>
<h2>The RAM trap: it's usually not your VRAM</h2>
<p>This one cost us the most time. On a card smaller than the model, <strong>the weights don't disappear; they
move to system RAM</strong>. GGUF loaders keep the weights in system RAM and stream them to the GPU. That
lowers VRAM use and raises RAM use, which is reported behaviour and consistent with our logs. So the
number that decides whether a long render finishes is often <strong>free system RAM</strong>.</p>
<p>What we measured on a 32 GB machine:</p>
<ul>
<li>One <strong>25-second (601-frame) clip with the refine pass took free RAM from 26.3 GB to 0.6 GB</strong>.
That's about <strong>25 GB for a single clip</strong>.</li>
<li>Renders started with much less than <strong>~20 GB free</strong> failed. Restarting ComfyUI gave back ~20 GB and
took ~50 s.</li>
<li>The failures didn't look like memory errors at first:
<code>hostbuf_file_reader_read failed</code> and <code>WinError 1450</code>, while free RAM sat at 2.1-2.5 GB mid-render.</li>
<li>The biggest variable was the web browser: <strong>0.6 GB on a quiet night, 5.2 GB with many tabs open</strong>.</li>
</ul>
<p>Rules of thumb that came out of it:</p>
<ul>
<li>Treat <strong>32 GB of system RAM as the practical floor</strong> for these workflows.</li>
<li><strong>Close the browser</strong> (or most of its tabs) before a batch.</li>
<li><strong>Restart ComfyUI between long clips.</strong></li>
<li><strong>Queue 2-3 jobs, not 10.</strong></li>
<li><strong>Shrink input videos first.</strong> A video loader may decode every frame to float32 in RAM, and a
1080x1920, 60 fps, 24-second clip works out to roughly 34 GB decoded. Resize it to half the output
size, the workflow's fps and the length you need with ffmpeg before it goes anywhere near ComfyUI.</li>
</ul>
<p>If you see <code>DefaultCPUAllocator: not enough memory</code>, <code>HostBuffer ... failed</code> or <code>WinError 1450</code>, it's
system RAM. Close apps, restart ComfyUI, shorten the clip. If you see
<code>Allocation on device ... would exceed allowed memory</code>, it's VRAM. Lower the resolution first, then
the frame count, then use a smaller quant.</p>
<p>Two launch flags we run with: <code>--reserve-vram 1.5</code> keeps VRAM headroom for the desktop, and
<code>--disable-pinned-memory</code> is a documented fix for pinned-memory failures. <code>--lowvram</code> is <em>reported</em> to
cost 20-40% speed. We haven't measured it, so use it only if a render won't fit otherwise.</p>
<h2>The linked-widget trap: numbers that do nothing</h2>
<p>You open a workflow, change <code>length</code> on <code>EmptyLTXVLatentVideo</code> from 97 to 121, queue it, and get the
same clip length as before. The workflow isn't broken.</p>
<p><strong>In ComfyUI, a widget whose input has a wire plugged into it is ignored.</strong> The node still prints a
number, but the value comes from whatever is on the other end of the wire. In a typical LTX graph,
the size, length and FPS on <code>EmptyLTXVLatentVideo</code>, <code>LTXVEmptyLatentAudio</code>, <code>LTXVConditioning</code> and
<code>CreateVideo</code> are all driven from primitives elsewhere. Editing the printed values changes nothing.</p>
<p>How to check any workflow:</p>
<ol>
<li><strong>Hover the input.</strong> If a link enters it, the widget is dead.</li>
<li><strong>Or read the JSON.</strong> In the node's <code>inputs</code> list, an entry with <code>"link": &lt;number&gt;</code> is driven from
elsewhere. Links are stored as <code>[id, from_node, from_slot, to_node, to_slot, type]</code>. Follow the
link back to the source node and edit that one.</li>
<li><strong>Expect indirection in shared workflows.</strong> The real control is sometimes several nodes away, for
example a "seconds" value feeding a math node that computes the frame count.</li>
</ol>
<p>The fix we settled on for our own graphs: put every live control (stage-1 width and height, frames,
FPS) in one clearly coloured group of primitives, and never make anyone edit a downstream widget.</p>
<h2>Settings that look like fixes but aren't</h2>
<ul>
<li><strong>The negative prompt.</strong> The distilled model runs at <strong>cfg 1.0</strong>, and at cfg 1.0 the negative branch
cancels out, so the negative prompt has no effect. Leave it empty.</li>
<li><strong>Raising cfg.</strong> We tried video/audio cfg 3.0/7.0 on the distilled model. The look got markedly
worse, and real cfg evaluates two branches every step, so it's slower too. Steer with the
<strong>positive prompt and the seed</strong> instead.</li>
<li><strong>More steps.</strong> Doubling steps costs more than any other lever gives back. Draft small, pick the
keeper, and spend the time there.</li>
<li><strong>"No zoom" in a static shot.</strong> If the camera moves when you asked for a locked shot, check the
prompt for camera-move words, including negated ones like "no zoom". Describe the locked-off camera
positively and delete every move word.</li>
</ul>
<p>Three small gotchas:</p>
<ul>
<li>A run that "finishes" in 0.01 s is ComfyUI returning a <strong>cached result</strong> for an identical prompt.
Change the seed.</li>
<li>Edits to a workflow <code>.json</code> on disk don't show up in an open tab, because the tab restores its own
copy of the graph. Load the file again.</li>
<li>If values land in the wrong widgets after loading, a combo option name probably differs in your
ComfyUI version and shifted later widgets by one. Re-select it and re-check the widgets below it.</li>
</ul>
<h2>Bonus: the learned upscaler works on video from any model</h2>
<p>The x2 upscaler only accepts LTX latents, but you can get any clip into one. <strong>Decode to pixels,
re-encode with LTX's own video VAE, upscale, decode.</strong> The model that produced the pixels stops
mattering at the pixel boundary. That works for another model's output, an older render or camera
footage. There are no sampler steps, which is why it's fast.</p>
<p>Measured: <strong>a 311-frame 480x864 clip went to 960x1728 in 80.1 s</strong>, with VRAM peaking around 17.9 GB.
The upsampler runs without tiling, so a longer or larger clip may need chunking.</p>
<p>Watch out for one thing: <strong>311 frames went in and 305 came out.</strong> The last 0.25 s was cut because LTX's
VAE works on the same 8n+1 grid. Feed it 8n+1 frame counts (121, 241, 345...) or trim to one first.
Also, the graph writes picture only, so copy the original audio back with ffmpeg (<code>-c:a copy</code>) rather
than re-encoding it.</p>
<h2>Try it</h2>
<p>We published the text-to-video version of this recipe as a free workflow:
<strong><a href="https://github.com/kanto-labs/comfyui-ltx-2.5-low-vram-workflow">comfyui-ltx-2.5-low-vram-workflow</a></strong>
on GitHub (MIT). It includes the three-stage graph with the live controls grouped in green, a frame and
size cheat-sheet inside the graph, and an installer that downloads the models from their publishers
and checks each file's SHA-256. If you want image-to-video, the any-video upscaler and a settings guide
with the numbers above, those are in the paid
<strong><a href="https://kantolabs.gumroad.com/l/fjnpqn">Low-VRAM AI Video Pack</a></strong>. <em>(Disclosure: Kanto Labs makes it.)</em></p>
<p>A licence note before you publish anything. The LTX-2.x Community License is free for commercial use
under $10M annual revenue, and <strong>published outputs must be disclosed as machine-generated</strong>. Read the
licence itself; our summary isn't legal advice.</p>
<h2>Checklist</h2>
<ul>
<li> Three stages: half-res 8-step draft → learned x2 latent upscale → 3-step refine</li>
<li> Frames are 8n+1 (121 = 5 s at 24 fps)</li>
<li> Stage-1 sides are multiples of 32; final = exactly 2x</li>
<li> Final size at or under ~1.2 MP on a 20 GB card (our measured ceiling)</li>
<li> ≥ ~20 GB free system RAM before a long clip; browser closed; ComfyUI restarted between long clips</li>
<li> Edited the primitive, not a linked widget</li>
<li> cfg 1.0, empty negative, steering with the positive prompt and the seed</li>
<li> Draft judged at stage 1 before paying for the full chain</li>
<li> AI-generated outputs labelled where you publish them</li>
</ul>
<hr />
<p><em>This article was written with AI assistance and reviewed by Kanto Labs. Every number in it comes
from our own renders on one RTX 3080 20 GB / 32 GB RAM machine unless it's marked as reported. We
haven't tested cards with 4-16 GB of VRAM.</em></p>
]]></content:encoded></item><item><title><![CDATA[How to Convert PDF to Markdown for LLMs in Python (3 Free Libraries Tested)]]></title><description><![CDATA[Short answer:

To keep headings and tables, use pymupdf4llm.to_markdown("file.pdf"), but check that its AGPL licence suits you.
For raw speed with correct word spacing, use pypdfium2 and add your own ]]></description><link>https://kanto-labs.hashnode.dev/how-to-convert-pdf-to-markdown-for-llms-in-python-3-free-libraries-tested</link><guid isPermaLink="true">https://kanto-labs.hashnode.dev/how-to-convert-pdf-to-markdown-for-llms-in-python-3-free-libraries-tested</guid><category><![CDATA[Python]]></category><category><![CDATA[llm]]></category><category><![CDATA[RAG ]]></category><category><![CDATA[AI]]></category><dc:creator><![CDATA[Kanto Labs]]></dc:creator><pubDate>Tue, 22 Sep 2026 22:34:16 GMT</pubDate><content:encoded><![CDATA[<p><strong>Short answer:</strong></p>
<ul>
<li><strong>To keep headings and tables</strong>, use <code>pymupdf4llm.to_markdown("file.pdf")</code>, but check that its AGPL licence suits you.</li>
<li><strong>For raw speed with correct word spacing</strong>, use <code>pypdfium2</code> and add your own page markers.</li>
<li><strong>For a whole folder of mixed Office files</strong>, use Microsoft's MarkItDown, but verify the PDF output, because the PDF path can lose spaces.</li>
</ul>
<p>Below is how we tested those claims, with the code we ran.</p>
<h2>Why Markdown, and not plain text?</h2>
<p>LLMs read Markdown well. <code>#</code> headings tell the model, and your chunker, where sections start. Tables
survive as <code>| a | b |</code> rows instead of a soup of numbers. Page markers let an answer cite "page 7".
Plain <code>extract_text()</code> output loses all three. That hurts retrieval, because a chunk that mixes the
end of "Methods" with the start of "Results" embeds badly.</p>
<h2>The test</h2>
<p>The test file was one real, digitally created PDF: <em>Attention Is All You Need</em> (arXiv 1706.03762,
15 pages, 2.2 MB). We ran it on a normal Windows laptop with Python 3.13, using pymupdf4llm 1.28.2,
markitdown 0.1.8 and pypdfium2 5.13.0.</p>
<pre><code class="language-bash">pip install pymupdf4llm "markitdown[pdf]" pypdfium2
curl -L -o attention.pdf https://arxiv.org/pdf/1706.03762
</code></pre>
<pre><code class="language-python">import time, pymupdf4llm, pypdfium2 as pdfium
from markitdown import MarkItDown

t = time.time(); md1 = pymupdf4llm.to_markdown("attention.pdf"); t1 = time.time() - t
t = time.time(); md2 = MarkItDown().convert("attention.pdf").text_content; t2 = time.time() - t

t = time.time()
pdf = pdfium.PdfDocument("attention.pdf")
pages = []
for i, page in enumerate(pdf, start=1):
    text = page.get_textpage().get_text_range()
    pages.append(f"&lt;!-- Page {i} --&gt;\n{text}")
md3 = "\n\n".join(pages); t3 = time.time() - t

for name, md, secs in [("pymupdf4llm", md1, t1), ("markitdown", md2, t2), ("pypdfium2", md3, t3)]:
    heads = [l for l in md.splitlines() if l.startswith("#")]
    print(f"{name:12s} {secs:5.2f}s words={len(md.split()):5d} headings={len(heads)}")
</code></pre>
<p>Results:</p>
<table>
<thead>
<tr>
<th>Library</th>
<th>Time</th>
<th>Words</th>
<th>Markdown headings</th>
<th>Notes</th>
</tr>
</thead>
<tbody><tr>
<td>pymupdf4llm</td>
<td>3.95 s</td>
<td>6,049</td>
<td>25</td>
<td><code># Attention Is All You Need</code>, <code>## 1 Introduction</code>, <code>### 3.1 Encoder and Decoder Stacks</code>…</td>
</tr>
<tr>
<td>MarkItDown</td>
<td>1.80 s</td>
<td>2,802</td>
<td>0</td>
<td>Spaces lost on many lines, e.g. <code>besuperiorinqualitywhilebeingmoreparallelizable</code></td>
</tr>
<tr>
<td>pypdfium2</td>
<td>0.11 s</td>
<td>6,177</td>
<td>0</td>
<td>Clean spacing; page markers come from our own loop</td>
</tr>
</tbody></table>
<p>The word count gives the problem away. The paper has about 6,000 words. MarkItDown's PDF path
reported 2,802 because many lines came out glued together. That is one PDF, not a verdict on every
file, but it is exactly the failure you want to catch <em>before</em> embedding 10,000 documents. MarkItDown
remains excellent for DOCX, PPTX and XLSX. It is the PDF route that needs checking.</p>
<h2>What each option is good at</h2>
<p><strong>pymupdf4llm</strong> (built on PyMuPDF). It rebuilds headings from font sizes and outputs Markdown tables
and bold/italic. Of the three, it gives the most "LLM-shaped" output. One cleanup tip: headings can
come out wrapped in bold (<code>## **1 Introduction**</code>), so strip the <code>*</code> characters if you store headings
as metadata. <strong>Licence:</strong> PyMuPDF is AGPL-3.0, with a commercial licence available. Check that it
fits a closed-source product before you ship it.</p>
<p><strong>pypdfium2</strong> (Chrome's PDF engine, Apache/BSD). It is by far the fastest and gets word spacing
right. It gives you plain text per page. That is often enough for RAG if you add page markers and
split by size. There are no headings, so chunks lose section context.</p>
<p><strong>MarkItDown</strong> (Microsoft, MIT). It is one API for PDF, Word, PowerPoint, Excel, HTML, EPUB and more.
It is very convenient for mixed folders. Spot-check its PDF output as shown above.</p>
<p><strong>Heavier options.</strong> If your PDFs are scans, or have complex multi-column layouts, look at
layout-model tools such as Docling or Marker. They add OCR and layout detection, but they are much
slower and usually want a GPU. None of the three libraries above does OCR: an image-only scan gives
you empty text.</p>
<h2>A quality check worth automating</h2>
<p>Before you embed anything, flag documents whose output looks broken:</p>
<pre><code class="language-python">def looks_broken(md: str) -&gt; bool:
    words = md.split()
    if len(words) &lt; 50:                          # empty or image-only PDF
        return True
    avg = sum(map(len, words)) / len(words)
    return avg &gt; 10                              # glued words push the average up
</code></pre>
<p>English prose averages 5-6 characters per "word" by this count. On our test, pymupdf4llm scored
5.8 and pypdfium2 scored 5.5. The MarkItDown output scored <strong>12.5</strong>. Send flagged files to a
different converter, or to OCR.</p>
<h2>Doing it for thousands of files, in any format</h2>
<p>Running this yourself means managing downloads, timeouts, format detection, and a converter per
file type. If your documents are URLs (S3 links, a CMS export, arXiv, a customer's shared folder),
our <strong><a href="https://apify.com/kantolabs/document-to-markdown">PDF &amp; Document to Markdown</a></strong> Apify actor
does the whole step. <em>(Disclosure: Kanto Labs makes it.)</em></p>
<ul>
<li>It converts PDFs with <strong>PDFium</strong> (the engine that won on spacing above) and marks every page with <code>&lt;!-- Page N --&gt;</code>.</li>
<li>It converts DOCX, PPTX, XLSX/XLS, EPUB, HTML, CSV, JSON, IPYNB, MSG and ZIP through MarkItDown.</li>
<li>It detects the file type from the bytes, so links without an extension still work.</li>
<li>It returns the page count, word count and a SHA-256 hash (useful for de-duplication).</li>
<li>It can optionally split the text into RAG chunks of the size you choose, each with its <code>headingPath</code>.</li>
</ul>
<pre><code class="language-bash">curl -X POST "https://api.apify.com/v2/acts/kantolabs~document-to-markdown/run-sync-get-dataset-items?token=$APIFY_TOKEN&amp;maxTotalChargeUsd=10" \
  -H "Content-Type: application/json" \
  -d '{"documentUrls": ["https://arxiv.org/pdf/1706.03762"], "chunkSize": 1500, "chunkOverlap": 150}'
</code></pre>
<p>On the same paper, a cloud run downloaded and converted the file in 277 ms and returned 15 pages,
6,177 words and page-marked Markdown.</p>
<p><strong>What it costs.</strong> $4 per 1,000 converted documents ($0.004 each), the same price for a 1-page memo
and a 300-page manual, with platform compute included. Converting a 2,000-PDF research archive costs
<strong>$8</strong>. Files that fail are <strong>not charged</strong>: download errors, files that are too large,
unsupported types, and scanned PDFs with no text layer all come back with a reason in <code>error</code>.</p>
<p><strong>What it will not do:</strong> OCR. If most of your files are scans or phone photos, use an OCR tool.</p>
<h2>Use it from an AI assistant</h2>
<p>Add the actor to Claude Desktop, Cursor or VS Code through Apify's MCP server:</p>
<pre><code class="language-json">{
  "mcpServers": {
    "apify": { "url": "https://mcp.apify.com?tools=kantolabs/document-to-markdown" }
  }
}
</code></pre>
<p>Sign in once with OAuth, or send an <code>Authorization: Bearer &lt;APIFY_TOKEN&gt;</code> header. Then ask, for
example, "convert these three report PDFs to Markdown and summarise the risks section". The assistant
passes the links to the actor and reads the Markdown back. Agents with no Apify account can pay per
run through Apify's x402 support, using a prepaid token bought with USDC on Base (minimum $1).</p>
<h2>Summary</h2>
<table>
<thead>
<tr>
<th>You need</th>
<th>Use</th>
</tr>
</thead>
<tbody><tr>
<td>Headings + tables from digital PDFs, AGPL is fine</td>
<td>pymupdf4llm</td>
</tr>
<tr>
<td>Maximum speed, correct spacing, page markers</td>
<td>pypdfium2 + your own markers</td>
</tr>
<tr>
<td>One API for Word, PowerPoint, Excel, HTML</td>
<td>MarkItDown (spot-check its PDF output)</td>
</tr>
<tr>
<td>Scans and complex layouts</td>
<td>an OCR/layout-model tool</td>
</tr>
<tr>
<td>Thousands of document URLs, any format, chunks included</td>
<td><a href="https://apify.com/kantolabs/document-to-markdown">Document to Markdown</a></td>
</tr>
</tbody></table>
<hr />
<p><em>This tutorial was drafted with AI assistance and reviewed by the Kanto Labs team; every code sample was run before publishing.</em></p>
]]></content:encoded></item></channel></rss>