|
| 1 | +# Plan: handle `data:` (and remote) URLs in graphics collection |
| 2 | + |
| 3 | +In broad strokes: Cache downloaded URL bytes in temp files, not in memory. |
| 4 | +Inspect image dimensions for all URLs, including `data:` as well as `https:` and |
| 5 | +others. Each URL is downloaded once, written to a temp file, and that path is |
| 6 | +reused everywhere downstream — so URL graphics flow through the existing |
| 7 | +`source_type == 'file'` code paths (inspect, hash, convert, copy) with almost no |
| 8 | +special-casing. |
| 9 | + |
| 10 | +## Caching scope: the **Feature** instance (not the DocumentManager) |
| 11 | + |
| 12 | +The download cache (bytes-on-disk + inspected info) lives on the |
| 13 | +`FeatureGraphicsCollection` **instance**, not on the per-document |
| 14 | +`DocumentManager`. The `DocumentManager` already references its owning feature |
| 15 | +(`self.feature`, set in `FeatureDocumentManagerBase.__init__`), and one Feature |
| 16 | +instance spawns every DocumentManager, so the cache is reachable from the scan |
| 17 | +code via `self.feature` with no plumbing. |
| 18 | + |
| 19 | +### Lifetime analysis (read before relying on cross-render reuse) |
| 20 | + |
| 21 | +- A single `main.run()` builds one environment, one set of Feature instances, |
| 22 | + and renders exactly **one** document (`run.py:1122/1164`; document-part |
| 23 | + fragments share that same document). For an ordinary CLI invocation, Feature |
| 24 | + scope and DocumentManager scope are therefore *identical in effect* — there is |
| 25 | + only ever one DocumentManager per Feature. |
| 26 | +- **Watch mode** rebuilds the environment and all Feature instances on every |
| 27 | + recompile (`watch.py:158` → `main.main()` → `run.py:372`; the `watch.py:145` |
| 28 | + comment deliberately avoids object reuse). A Feature-level cache does **not** |
| 29 | + survive across recompiles — each save re-downloads. |
| 30 | +- Feature scope therefore only beats DocumentManager scope when one environment |
| 31 | + is deliberately reused across several `make_document`/render passes (custom |
| 32 | + batch driver, multi-format render off one env). It is a strict superset of |
| 33 | + per-document dedup and is otherwise harmless. |
| 34 | +- **Follow-up (out of scope here):** to cache downloads across watch-mode |
| 35 | + recompiles, persistence must move to an even broader scope (a module-level |
| 36 | + dict keyed by URL, or persisting the environment across recompiles). Not done |
| 37 | + in this plan. |
| 38 | + |
| 39 | +Because the Feature can outlive any single DocumentManager, the temp directory |
| 40 | +and the temp-file paths it hands out must remain valid for the whole Feature |
| 41 | +lifetime — which they do (see "temp-file lifetime" below). |
| 42 | + |
| 43 | +## Plan |
| 44 | + |
| 45 | +**0. Feature-level cache state (in `FeatureGraphicsCollection.__init__`).** |
| 46 | +Add, alongside the existing config attributes: |
| 47 | +- `self._url_cache = {}` — keyed by the URL string (`source_url`); value is a |
| 48 | + dict `{ 'temp_file_path', 'mimetype', 'detected_ext', 'info', 'input_hash' }` |
| 49 | + (or a failure marker, see step 3). |
| 50 | +- `self._url_tempdir = None` — created lazily on first URL download via |
| 51 | + `tempfile.TemporaryDirectory()` and stored on the Feature, so the temp files' |
| 52 | + lifetime is tied to the Feature lifetime. |
| 53 | +- `self._url_download_counter = 0` — monotonic, Feature-scoped, used to name temp |
| 54 | + files uniquely within the shared temp dir (per-document counters could collide |
| 55 | + in a shared dir). |
| 56 | + |
| 57 | +These are plain attributes set in `__init__`; Transcrypt-safe. Mixing runtime |
| 58 | +cache state onto the (otherwise config-only) Feature object is intentional and |
| 59 | +required by the chosen scope. |
| 60 | + |
| 61 | +**1. New Feature method `fetch_inspect_url(source_url)` — the memoizing core.** |
| 62 | +Lives on the Feature (it owns the cache + temp dir + already owns |
| 63 | +`inspect_graphics_file`). Behavior: |
| 64 | +- If `source_url in self._url_cache`, return the cached entry immediately (this is |
| 65 | + the dedup / single-download guarantee, now Feature-wide). |
| 66 | +- Otherwise download + inspect once: |
| 67 | + - `with urllib.request.urlopen(source_url) as r: content = r.read(); mimetype = r.headers.get_content_type()` — the default opener already has `DataHandler`, so `data:` is handled offline (the same call `collect_graphics` already uses). |
| 68 | + - `detected_ext` from a small override dict (`image/png`→`.png`, `image/jpeg`→`.jpg`, `image/svg+xml`→`.svg`, `application/pdf`→`.pdf`, `image/gif`→`.gif`), falling back to `mimetypes.guess_extension(mimetype)` (default `''`). |
| 69 | + - Lazily create `self._url_tempdir` if `None`; bump `self._url_download_counter`; write `content` to a temp file named `inline{counter}{detected_ext}` inside the temp dir, so the synthetic name carries the right extension for the inspector and converters. |
| 70 | + - Inspect by opening that temp file: `with open(tmp_path, 'rb') as fp: info = self.inspect_graphics_file(tmp_path, fp)`. |
| 71 | + - Compute `input_hash` once here from `content` (`hashlib.sha256(content).hexdigest()`), so it's cached with the entry and reused for `${hash}`/cache-skip. |
| 72 | + - Store and return `{ 'temp_file_path': tmp_path, 'mimetype', 'detected_ext', 'info': info or {}, 'input_hash' }`. |
| 73 | +- Wrap fetch+inspect in try/except: on failure, **warn and cache a failure marker** |
| 74 | + `{ 'temp_file_path': None, 'mimetype': None, 'detected_ext': '', 'info': {}, 'input_hash': None }` (preserves today's behavior for unreachable/odd URLs — no hard regression, and we don't retry a known-bad URL on every document). |
| 75 | + |
| 76 | +**2. `get_source_info` — document the multi-call contract, keep it cheap.** |
| 77 | +Add a comment that it runs once per resource at scan *and* once per |
| 78 | +`\includegraphics` at render (`:1207`), only to classify the source and compute a |
| 79 | +stable `source_key`. No network / decode / disk I/O here. Return tuple and |
| 80 | +`source_resolved` unchanged; `data:` URLs keep classifying as `'url'`. |
| 81 | + |
| 82 | +**3. Scan branch uses the Feature cache (the `# TODO`, `:839`).** |
| 83 | +Rewrite the `elif source_type == 'url':` branch in |
| 84 | +`inspect_add_graphics_resource` to delegate: |
| 85 | +- `entry = self.feature.fetch_inspect_url(source_url)` (downloads at most once |
| 86 | + per URL per Feature lifetime; later documents/renders reuse it). |
| 87 | +- `graphics_resource = GraphicsResource(src_url=source_url, **entry['info'])`. |
| 88 | +- `self.add_graphics(source_key, source_info, graphics_resource, url_entry=entry)`. |
| 89 | + |
| 90 | +**4. Cache resolved paths in the per-document collection entry.** |
| 91 | +Have `add_graphics` (`:858`) accept an optional `url_entry` and store, in |
| 92 | +`graphics_collection[source_key]`, the extra fields `temp_file_path` (str or |
| 93 | +`None`), `mimetype`, `detected_ext`, and `input_hash`, copied from `url_entry`. |
| 94 | +For `file` sources (`url_entry=None`), set |
| 95 | +`detected_ext = splitext(full_file_path)[1]`, `temp_file_path=None`, |
| 96 | +`mimetype=None`, `input_hash=None`, so the fields are uniformly present. This |
| 97 | +per-document dict is internal, never serialized — `GraphicsResource` stays clean. |
| 98 | +The `temp_file_path` it stores points into the Feature's temp dir, which outlives |
| 99 | +the DocumentManager, so it is valid throughout scan→render→collect. |
| 100 | + |
| 101 | +**5. `prepare_collect_graphics` — use `detected_ext` and the cached hash (the `# TODO (2)`).** |
| 102 | +For `source_type == 'url'`, take `ext` from the entry's `detected_ext` (not |
| 103 | +`splitext` of the data URL, which yields garbage), and take `input_hash` from the |
| 104 | +entry (already computed by the Feature in step 1 — no second read). For `file` |
| 105 | +sources keep the existing `hashlib.file_digest(f, 'sha256')` path. So URL graphics |
| 106 | +get cache-skip and `${hash}`/`${hash6}` template keys exactly like files. |
| 107 | + |
| 108 | +**6. `collect_graphics` — copy/convert from the temp file.** |
| 109 | +In the url branch (`:1170`), if a `temp_file_path` is available (looked up via |
| 110 | +`source_key`, or threaded into `collect_info` in step 5), treat it like a local |
| 111 | +file: `shutil.copyfile(temp_file_path, target_path)` for the no-converter path, |
| 112 | +and pass the temp path (with `source_type='file'`) to `converter.convert(...)` so |
| 113 | +converters read it off disk with no second download. Fall back to the existing |
| 114 | +`urllib.request.urlopen` copy only when there is no temp file (earlier fetch |
| 115 | +failed). Converters stay completely untouched. |
| 116 | + |
| 117 | +**7. Guard the non-collecting `relpath` (`:1228`).** |
| 118 | +`os.path.relpath` only when `source_type == 'file'`; otherwise return `src_url` |
| 119 | +unchanged so inline `data:` / remote URLs survive verbatim in non-collecting |
| 120 | +output. |
| 121 | + |
| 122 | +**8. Imports:** add `tempfile` and `mimetypes` at module top (alongside existing |
| 123 | +`urllib.request`). (`io` not needed — inspection opens the temp file directly.) |
| 124 | + |
| 125 | +**9. Tests.** Small base64 PNG `data:` URL through both a collecting render |
| 126 | +(assert the collected file ends `.png`, exists on disk, and the |
| 127 | +`GraphicsResource` has `physical_dimensions`) and a non-collecting render (assert |
| 128 | +the data URL passes through intact). Add one test that reuses a single |
| 129 | +`FeatureGraphicsCollection` instance across two documents and asserts the URL is |
| 130 | +fetched only once (e.g. by checking `len(feature._url_cache) == 1`, or by |
| 131 | +monkeypatching/counting `urlopen`) — this is what the Feature-scoped cache buys. |
| 132 | +Use a temp output dir; allowed assertion methods only |
| 133 | +(`assertTrue`/`assertEqual`/etc.). |
| 134 | + |
| 135 | +## Notes on temp-file lifetime |
| 136 | + |
| 137 | +- The `TemporaryDirectory` is created lazily (only if at least one URL is |
| 138 | + downloaded) and held on the **Feature**, so it persists across every document |
| 139 | + and render/collect pass that Feature serves, and is removed automatically when |
| 140 | + the Feature is garbage-collected. No unbounded in-memory bytes are retained |
| 141 | + (only the bytes-on-disk cache, bounded by the number of distinct URLs). |
| 142 | +- Deduplication is per URL across the whole Feature lifetime: a repeated identical |
| 143 | + URL — within one document *or across documents that share the Feature* — is |
| 144 | + downloaded to a temp file once. |
| 145 | +- Caveat (restated): in standard single-document runs and in watch mode the |
| 146 | + Feature is not reused across renders, so the cache does not persist across them. |
| 147 | + See the lifetime analysis above. |
| 148 | + |
| 149 | +Shall I implement this? |
0 commit comments