diff --git a/CHANGELOG.md b/CHANGELOG.md
index b7d23ac..5e354f6 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,29 @@ All notable changes to this project are documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
+## 0.6.5 — 2026-10-14
+
+A patch release that lets a caller find the Document behind an image GLPI left
+as a raw `data:` URI. GLPI may keep a large pasted image in the stored body as
+`` instead of rewriting it into
+a document link, while still creating its Document, whose tag equals the
+image's `id`. Existing methods are unchanged.
+
+### Added
+
+- **`Link.image_id`**, a new last field of the `Link` the `rewrite_link`
+ callback receives: for an `
`, the value of its `id` attribute (trimmed;
+ `None` when absent or blank); always `None` for a link.
+- **`GlpiContentConverter.data_image_ids(html)`** returns, in document order
+ and without duplicates, the `id` of every `
` whose `src` is a `data:`
+ URI, so a caller can tell whether it needs to read any Document at all. It
+ does not decode the base64, and leaves out an image inside `
`, ``,
+ `` or ``, which the reader never offers to `rewrite_link`.
+- **`GetDocument.tag`**, a read-only property: the Document's tag from
+ `extra_payload`, or `None` when absent or not a non-empty string. GLPI sets a
+ tag on the Documents it creates from an upload or an editor paste, images or
+ not.
+
## 0.6.4 — 2026-10-12
A patch release that reads the documents GLPI links to a followup. GLPI's
diff --git a/docs/user_guide.rst b/docs/user_guide.rst
index f27c045..be755ca 100644
--- a/docs/user_guide.rst
+++ b/docs/user_guide.rst
@@ -2007,6 +2007,25 @@ other type raises ``TypeError``. It is not called for a value passed through
as Markdown (``plain_text_is_markdown=True``), nor for a body read as its text.
``easyvista-python-client`` 0.4.3 carries the same hook.
+GLPI does not always rewrite a pasted image into a document link. A large one
+can stay in the stored body as ``
``
+while GLPI still creates its Document, whose tag is the image's ``id``. For
+such an image ``Link.image_id`` is that ``id`` and ``href`` is the data URI;
+``GlpiContentConverter.data_image_ids(html)`` lists those ids without
+converting, so a caller reads Documents only when a body needs it. A
+:class:`~glpi_python_client.models.GetDocument` exposes its tag as ``tag``:
+
+.. code-block:: python
+
+ ids = GlpiContentConverter.data_image_ids(body)
+ if ids:
+ by_tag = {d.tag: d for d in documents if d.tag}
+ matched = [by_tag[i] for i in ids if i in by_tag]
+
+The ``id`` is body text, and anyone who can write the body can forge it. Match
+it only against Documents the caller already trusts, such as the same
+ticket's own, never against every Document the instance holds.
+
It is not a sanitiser
^^^^^^^^^^^^^^^^^^^^^
diff --git a/glpi_python_client/__init__.py b/glpi_python_client/__init__.py
index 7b8a525..386ed73 100644
--- a/glpi_python_client/__init__.py
+++ b/glpi_python_client/__init__.py
@@ -133,7 +133,7 @@
date_window,
)
-__version__ = "0.6.4"
+__version__ = "0.6.5"
__all__ = [
"AsyncGlpiClient",
diff --git a/glpi_python_client/content/conversion.py b/glpi_python_client/content/conversion.py
index 0a7f826..b45e9bd 100644
--- a/glpi_python_client/content/conversion.py
+++ b/glpi_python_client/content/conversion.py
@@ -179,6 +179,30 @@ def _plain_text_html(text: str) -> str:
return "" + "
".join(lines) + "
"
+#: What a browser strips from the front of a URL, and drops from inside one.
+_URL_LEADING = "".join(chr(c) for c in range(0x21))
+_URL_INNER = str.maketrans("", "", "\t\r\n")
+
+#: The elements inside which the reader writes no image, so offers none.
+_UNFORMATTED = ("pre", "code", "kbd", "samp")
+
+
+def _is_data_uri(src: str) -> bool:
+ """Whether a browser reads ``src`` as a ``data:`` URI.
+
+ The scheme is case-insensitive, leading C0 controls and spaces are
+ stripped, and tabs and line breaks are dropped wherever they are.
+ """
+
+ return src.lstrip(_URL_LEADING).translate(_URL_INNER).casefold().startswith("data:")
+
+
+def _image_id(el: Tag) -> str | None:
+ """The trimmed ``id`` of an element, or ``None`` when it has none."""
+
+ return str(el.get("id") or "").strip() or None
+
+
def _soup(html: str) -> BeautifulSoup:
"""Parse ``html`` and drop what a browser does not display."""
@@ -402,6 +426,12 @@ class Link:
enclosing_href : str or None
For an ``
`` inside a link, that link's ``href``; ``None``
otherwise. The image is read before the link around it.
+ image_id : str or None
+ For an ``
``, the value of its ``id`` attribute, trimmed, or
+ ``None`` when it has none or the element is a link. GLPI sets it to
+ the tag of the Document it created for an image it has not rewritten
+ into a document link, and that Document carries the same value as its
+ tag, so a caller can find it.
"""
href: str
@@ -410,6 +440,7 @@ class Link:
image: bool = False
document_id: int | None = None
enclosing_href: str | None = None
+ image_id: str | None = None
#: A ``rewrite_link`` callback: called with each link and image a read meets,
@@ -664,6 +695,7 @@ def convert_img(self, el: Tag, text: str, parent_tags: set[str]) -> str:
enclosing_href=(
None if around is None else str(around.get("href") or "")
),
+ image_id=_image_id(el),
)
)
if isinstance(answer, str):
@@ -1021,6 +1053,33 @@ def document_id_of(url: str) -> int | None:
document_id = int(values[0])
return document_id if document_id > 0 else None
+ @staticmethod
+ def data_image_ids(html: str) -> tuple[str, ...]:
+ """Return the ``id`` of every image a body keeps as a ``data:`` URI.
+
+ Each ``
`` whose ``src`` a browser reads as a ``data:`` URI and
+ that has a non-empty ``id`` contributes it, in document order and
+ without repeats. The base64 is not decoded. An image inside ````,
+ ````, ```` or ```` is left out: the reader shows it
+ as no image, so it never offers it to ``rewrite_link`` either.
+
+ GLPI sets a tag on the Documents it creates from an upload or an
+ editor paste, images or not. For an image it has not rewritten into a
+ document link, that tag is the ``id`` of the ``
``
+ (:attr:`Link.image_id`, :attr:`GetDocument.tag`). A caller calls this
+ first, to decide whether it needs to read any Document at all.
+ """
+
+ if "<" not in html:
+ return ()
+ ids = (
+ _image_id(img)
+ for img in _soup(html).find_all("img")
+ if _is_data_uri(str(img.get("src") or ""))
+ and img.find_parent(_UNFORMATTED) is None
+ )
+ return tuple(dict.fromkeys(i for i in ids if i is not None))
+
@staticmethod
def document_image(
document_id: int,
diff --git a/glpi_python_client/content/tests/test_cost.py b/glpi_python_client/content/tests/test_cost.py
index 6c0841c..7f93f20 100644
--- a/glpi_python_client/content/tests/test_cost.py
+++ b/glpi_python_client/content/tests/test_cost.py
@@ -242,3 +242,20 @@ def test_a_body_too_deep_to_convert_keeps_its_text() -> None:
assert "init" in markdown
assert "au fond" in render(markdown)
+
+
+def test_a_large_data_uri_is_read_within_the_cost_bound() -> None:
+ """A pasted image GLPI keeps as a raw data URI runs to about a megabyte."""
+
+ html = (
+ '
'
+ )
+
+ started = time.perf_counter()
+ seen: list[object] = []
+ GlpiContentConverter.from_transport(html, rewrite_link=seen.append)
+
+ assert len(seen) == 1
+ assert time.perf_counter() - started < 10
diff --git a/glpi_python_client/content/tests/test_rewrite_link.py b/glpi_python_client/content/tests/test_rewrite_link.py
index 7750ea1..ca18a96 100644
--- a/glpi_python_client/content/tests/test_rewrite_link.py
+++ b/glpi_python_client/content/tests/test_rewrite_link.py
@@ -16,6 +16,7 @@
from glpi_python_client import GlpiValidationError
from glpi_python_client.content import GlpiContentConverter, Link, RewriteLink
+from glpi_python_client.content.conversion import _is_data_uri
from glpi_python_client.content.tests.test_round_trip import (
E_MAIL_SHAPES,
EARLIER_REGRESSIONS,
@@ -26,6 +27,7 @@
render = GlpiContentConverter.to_transport
document_image = GlpiContentConverter.document_image
document_id_of = GlpiContentConverter.document_id_of
+data_image_ids = GlpiContentConverter.data_image_ids
#: What every rendered link carries: GLPI's editor writes it on a link it makes.
NEW_WINDOW = ' target="_blank" rel="noopener noreferrer"'
@@ -39,6 +41,11 @@
)
+#: A pasted image GLPI left as a raw data URI (synthetic tag and bytes).
+DATA_URI = "data:image/png;base64,iVBORw0KGgo="
+DATA_IMAGE = f'
'
+
+
def offered(html: str) -> list[Link]:
"""Read ``html`` with a callback that keeps everything, and return what it saw."""
@@ -60,12 +67,67 @@ def keep(link: Link) -> None:
"html",
REALISTIC
+ E_MAIL_SHAPES
- + [html for bodies in EARLIER_REGRESSIONS.values() for html in bodies],
+ + [html for bodies in EARLIER_REGRESSIONS.values() for html in bodies]
+ + [DATA_IMAGE],
)
def test_a_callback_answering_none_changes_nothing(html: str) -> None:
assert read(html, rewrite_link=lambda link: None) == read(html)
+def test_a_pasted_data_image_is_offered_with_its_id() -> None:
+ assert offered(DATA_IMAGE) == [
+ Link(href=DATA_URI, image=True, image_id="tag-1", document_id=None)
+ ]
+
+
+def test_an_image_without_an_id_has_no_image_id() -> None:
+ (link,) = offered(f'
')
+
+ assert link.image_id is None
+
+
+@pytest.mark.parametrize("attribute", ['id=""', 'id=" "', "id"])
+def test_an_empty_or_blank_id_reads_as_none(attribute: str) -> None:
+ (link,) = offered(f'
')
+
+ assert link.image_id is None
+
+
+def test_an_image_inside_a_link_keeps_its_own_id_and_the_link_has_none() -> None:
+ html = (
+ ''
+ )
+
+ image, anchor = offered(html)
+
+ assert image.image_id == "tag-2"
+ assert anchor.image_id is None
+
+
+def test_an_id_is_trimmed() -> None:
+ (link,) = offered(f'
')
+
+ assert link.image_id == "tag-3"
+
+
+def test_link_positional_construction_still_works() -> None:
+ link = Link("h", "t", "ti", True, 5, "https://x.example")
+
+ assert (link.href, link.text, link.title, link.image) == ("h", "t", "ti", True)
+ assert (link.document_id, link.enclosing_href) == (5, "https://x.example")
+ assert link.image_id is None
+
+
+def test_answering_with_a_link_rewrites_a_data_image() -> None:
+ markdown = read(
+ DATA_IMAGE,
+ rewrite_link=lambda link: Link("https://files.example/9", "alt", image=True),
+ )
+
+ assert markdown == ""
+
+
def test_each_link_and_image_is_offered_once_an_image_before_its_link() -> None:
html = (
''
+ )
+
+ assert data_image_ids(html) == ()
+
+
+def test_data_image_ids_sees_a_scheme_in_mixed_case_spaced_and_split() -> None:
+ html = '
'
+
+ assert data_image_ids(html) == ("t",)
+
+
+@pytest.mark.parametrize("lead", ["\x01", "\x0b", "\x1f", " \x0b\x01 "])
+def test_data_image_ids_strips_leading_control_characters(lead: str) -> None:
+ html = f'
'
+
+ assert data_image_ids(html) == ("c",)
+
+
+def test_data_image_ids_does_not_strip_a_leading_no_break_space() -> None:
+ html = '
'
+
+ assert data_image_ids(html) == ()
+
+
+@pytest.mark.parametrize("wrapper", ["pre", "code", "kbd", "samp"])
+def test_data_image_ids_skips_an_image_the_reader_never_offers(wrapper: str) -> None:
+ html = f'<{wrapper}>
{wrapper}>'
+
+ assert data_image_ids(html) == ()
+
+
+def test_data_image_ids_skips_an_image_nested_inside_code() -> None:
+ html = (
+ f'x
'
+ f'
'
+ )
+
+ assert data_image_ids(html) == ("b",)
+
+
+@pytest.mark.parametrize("html", ["", "plain text", "no image
"])
+def test_data_image_ids_of_an_empty_body_is_empty(html: str) -> None:
+ assert data_image_ids(html) == ()
+
+
+@pytest.mark.parametrize(
+ "html",
+ [
+ DATA_IMAGE,
+ '
',
+ '
',
+ '
',
+ '
',
+ '
',
+ '
',
+ '
',
+ '
',
+ '
',
+ '
',
+ '',
+ '

',
+ '
',
+ '
',
+ '
',
+ '
',
+ 'x
',
+ '
'
+ '
',
+ ],
+)
+def test_data_image_ids_agrees_with_the_ids_the_reader_offers(html: str) -> None:
+ from_the_reader = tuple(
+ dict.fromkeys(
+ link.image_id
+ for link in offered(html)
+ if link.image and link.image_id and _is_data_uri(link.href)
+ )
+ )
+
+ assert data_image_ids(html) == from_the_reader
diff --git a/glpi_python_client/models/api_schema/management/_document.py b/glpi_python_client/models/api_schema/management/_document.py
index 83e090a..b176f94 100644
--- a/glpi_python_client/models/api_schema/management/_document.py
+++ b/glpi_python_client/models/api_schema/management/_document.py
@@ -61,6 +61,20 @@ class GetDocument(GlpiModel):
mime: str | None = None
sha1sum: str | None = None
+ @property
+ def tag(self) -> str | None:
+ """The tag GLPI gave the Document, or ``None``.
+
+ GLPI sets a tag on the Documents it creates from an upload or an
+ editor paste, images or not. For an image it has not rewritten into
+ a document link, it is the ``id`` of the ``
`` left in the body
+ (see ``GlpiContentConverter.data_image_ids``). Read from
+ ``extra_payload``; ``None`` when absent, empty or not a string.
+ """
+
+ value = self.extra_payload.get("tag")
+ return value if isinstance(value, str) and value else None
+
class PostDocument(GlpiModel):
"""Request body for ``POST /Management/Document``.
diff --git a/glpi_python_client/models/api_schema/management/tests/test_management_schemas.py b/glpi_python_client/models/api_schema/management/tests/test_management_schemas.py
index 1ef125d..dab11ad 100644
--- a/glpi_python_client/models/api_schema/management/tests/test_management_schemas.py
+++ b/glpi_python_client/models/api_schema/management/tests/test_management_schemas.py
@@ -2,6 +2,8 @@
from __future__ import annotations
+import pytest
+
from glpi_python_client.models.api_schema.management import (
DeleteDocument,
GetDocument,
@@ -48,3 +50,18 @@ def test_delete_document_default() -> None:
"""``DeleteDocument`` exposes ``force`` as optional."""
assert DeleteDocument().force is None
+
+
+def test_get_document_tag_reads_the_extra_payload() -> None:
+ doc = GetDocument.model_validate({"id": 1, "tag": "tag-1"})
+
+ assert doc.tag == "tag-1"
+
+
+def test_get_document_tag_is_none_when_absent() -> None:
+ assert GetDocument.model_validate({"id": 1}).tag is None
+
+
+@pytest.mark.parametrize("value", [None, "", 7, ["tag-1"]])
+def test_get_document_tag_is_none_when_not_a_non_empty_string(value: object) -> None:
+ assert GetDocument.model_validate({"id": 1, "tag": value}).tag is None
diff --git a/pyproject.toml b/pyproject.toml
index 802d8a2..a6fd02c 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -35,7 +35,7 @@ exclude = [
[project]
name = "glpi-python-client"
-version = "0.6.4"
+version = "0.6.5"
description = "A typed Python client for GLPI ITSM APIs."
readme = "README.md"
requires-python = ">=3.11"
diff --git a/skills/glpi-asset-workflow/SKILL.md b/skills/glpi-asset-workflow/SKILL.md
index 883a92d..188d137 100644
--- a/skills/glpi-asset-workflow/SKILL.md
+++ b/skills/glpi-asset-workflow/SKILL.md
@@ -5,7 +5,7 @@ license: MIT
compatibility: "Requires Python 3.11+, glpi-python-client, network access to the GLPI v2 API, and credentials allowed to read or write assets."
metadata:
package: glpi-python-client
- version: "0.6.4"
+ version: "0.6.5"
---
# GLPI Asset Workflow
diff --git a/skills/glpi-client-setup/SKILL.md b/skills/glpi-client-setup/SKILL.md
index 1de40c9..2d0ee23 100644
--- a/skills/glpi-client-setup/SKILL.md
+++ b/skills/glpi-client-setup/SKILL.md
@@ -5,7 +5,7 @@ license: MIT
compatibility: "Requires Python 3.11+, glpi-python-client, network access to a GLPI v2 API, and valid GLPI credentials."
metadata:
package: glpi-python-client
- version: "0.6.4"
+ version: "0.6.5"
---
# GLPI Client Setup
diff --git a/skills/glpi-contract-workflow/SKILL.md b/skills/glpi-contract-workflow/SKILL.md
index 46ae603..704189b 100644
--- a/skills/glpi-contract-workflow/SKILL.md
+++ b/skills/glpi-contract-workflow/SKILL.md
@@ -5,7 +5,7 @@ license: MIT
compatibility: "Requires Python 3.11+, glpi-python-client, network access to the GLPI v2 API, and credentials allowed to read or write contracts."
metadata:
package: glpi-python-client
- version: "0.6.4"
+ version: "0.6.5"
---
# GLPI Contract Workflow
diff --git a/skills/glpi-document-workflow/SKILL.md b/skills/glpi-document-workflow/SKILL.md
index d73761b..1ab3445 100644
--- a/skills/glpi-document-workflow/SKILL.md
+++ b/skills/glpi-document-workflow/SKILL.md
@@ -5,7 +5,7 @@ license: MIT
compatibility: "Requires Python 3.11+, glpi-python-client, network access to the GLPI v2 API, and v1 credentials configured on the client for binary uploads."
metadata:
package: glpi-python-client
- version: "0.6.4"
+ version: "0.6.5"
---
# GLPI Document Workflow
diff --git a/skills/glpi-knowledge-base/SKILL.md b/skills/glpi-knowledge-base/SKILL.md
index 2c78d7e..3505808 100644
--- a/skills/glpi-knowledge-base/SKILL.md
+++ b/skills/glpi-knowledge-base/SKILL.md
@@ -5,7 +5,7 @@ license: MIT
compatibility: "Requires Python 3.11+, glpi-python-client, network access to the GLPI v2 API, and — for category writes only — a legacy v1 session (v1_base_url + v1_user_token)."
metadata:
package: glpi-python-client
- version: "0.6.4"
+ version: "0.6.5"
---
# GLPI Knowledge Base
diff --git a/skills/glpi-plugin-fields/SKILL.md b/skills/glpi-plugin-fields/SKILL.md
index 56a1532..5fb9156 100644
--- a/skills/glpi-plugin-fields/SKILL.md
+++ b/skills/glpi-plugin-fields/SKILL.md
@@ -5,7 +5,7 @@ license: MIT
compatibility: "Requires Python 3.11+, glpi-python-client, the GLPI Fields plugin installed server-side, and a legacy v1 session (v1_base_url + v1_user_token) — every method in this family goes over the v1 API."
metadata:
package: glpi-python-client
- version: "0.6.4"
+ version: "0.6.5"
---
# GLPI Plugin Fields
diff --git a/skills/glpi-reporting-and-context/SKILL.md b/skills/glpi-reporting-and-context/SKILL.md
index 1eb2fe7..54719b0 100644
--- a/skills/glpi-reporting-and-context/SKILL.md
+++ b/skills/glpi-reporting-and-context/SKILL.md
@@ -5,7 +5,7 @@ license: MIT
compatibility: "Requires Python 3.11+, glpi-python-client, network access to the GLPI v2 API, and credentials allowed to read tickets, tasks, users, entities, and timeline records."
metadata:
package: glpi-python-client
- version: "0.6.4"
+ version: "0.6.5"
---
# GLPI Reporting And Context
diff --git a/skills/glpi-team-members/SKILL.md b/skills/glpi-team-members/SKILL.md
index 9fb9513..3d0fdb7 100644
--- a/skills/glpi-team-members/SKILL.md
+++ b/skills/glpi-team-members/SKILL.md
@@ -5,7 +5,7 @@ license: MIT
compatibility: "Requires Python 3.11+, glpi-python-client, network access to the GLPI v2 API, and credentials allowed to manage ticket teams."
metadata:
package: glpi-python-client
- version: "0.6.4"
+ version: "0.6.5"
---
# GLPI Team Members
diff --git a/skills/glpi-ticket-timeline/SKILL.md b/skills/glpi-ticket-timeline/SKILL.md
index f0aa959..0893a29 100644
--- a/skills/glpi-ticket-timeline/SKILL.md
+++ b/skills/glpi-ticket-timeline/SKILL.md
@@ -5,7 +5,7 @@ license: MIT
compatibility: "Requires Python 3.11+, glpi-python-client, and network access to the GLPI v2 API."
metadata:
package: glpi-python-client
- version: "0.6.4"
+ version: "0.6.5"
---
# GLPI Ticket Timeline
diff --git a/skills/glpi-ticket-workflow/SKILL.md b/skills/glpi-ticket-workflow/SKILL.md
index 219bd52..ee7e0d0 100644
--- a/skills/glpi-ticket-workflow/SKILL.md
+++ b/skills/glpi-ticket-workflow/SKILL.md
@@ -5,7 +5,7 @@ license: MIT
compatibility: "Requires Python 3.11+, glpi-python-client, network access to the GLPI v2 API, and credentials accepted by GlpiClient."
metadata:
package: glpi-python-client
- version: "0.6.4"
+ version: "0.6.5"
---
# GLPI Ticket Workflow
diff --git a/skills/glpi-user-location-provisioning/SKILL.md b/skills/glpi-user-location-provisioning/SKILL.md
index 0e7d941..7763019 100644
--- a/skills/glpi-user-location-provisioning/SKILL.md
+++ b/skills/glpi-user-location-provisioning/SKILL.md
@@ -5,7 +5,7 @@ license: MIT
compatibility: "Requires Python 3.11+, glpi-python-client, network access to the GLPI v2 API, and credentials allowed to read or write users, locations, and entities."
metadata:
package: glpi-python-client
- version: "0.6.4"
+ version: "0.6.5"
---
# GLPI User, Location, And Entity Provisioning