Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
`<img id="..." src="data:image/png;base64,...">` 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 `<img>`, 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 `<img>` 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 `<pre>`, `<code>`,
`<kbd>` or `<samp>`, 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
Expand Down
19 changes: 19 additions & 0 deletions docs/user_guide.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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 ``<img id="tag-1" src="data:image/png;base64,...">``
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
^^^^^^^^^^^^^^^^^^^^^

Expand Down
2 changes: 1 addition & 1 deletion glpi_python_client/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,7 @@
date_window,
)

__version__ = "0.6.4"
__version__ = "0.6.5"

__all__ = [
"AsyncGlpiClient",
Expand Down
59 changes: 59 additions & 0 deletions glpi_python_client/content/conversion.py
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,30 @@ def _plain_text_html(text: str) -> str:
return "<p>" + "<br>".join(lines) + "</p>"


#: 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."""

Expand Down Expand Up @@ -402,6 +426,12 @@ class Link:
enclosing_href : str or None
For an ``<img>`` 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 ``<img>``, 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
Expand All @@ -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,
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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 ``<img>`` 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 ``<pre>``,
``<code>``, ``<kbd>`` or ``<samp>`` 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 ``<img>``
(: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,
Expand Down
17 changes: 17 additions & 0 deletions glpi_python_client/content/tests/test_cost.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = (
'<p><img id="tag-1" src="data:image/png;base64,'
+ "iVBORw0KGgo=" * 80_000
+ '"></p>'
)

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
183 changes: 182 additions & 1 deletion glpi_python_client/content/tests/test_rewrite_link.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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"'
Expand All @@ -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'<p><img id="tag-1" src="{DATA_URI}" data-upload_id="x"></p>'


def offered(html: str) -> list[Link]:
"""Read ``html`` with a callback that keeps everything, and return what it saw."""

Expand All @@ -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'<p><img src="{DATA_URI}"></p>')

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'<p><img {attribute} src="{DATA_URI}"></p>')

assert link.image_id is None


def test_an_image_inside_a_link_keeps_its_own_id_and_the_link_has_none() -> None:
html = (
'<p><a id="link-id" href="https://x.example/p">'
f'<img id="tag-2" src="{DATA_URI}"></a></p>'
)

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'<p><img id=" tag-3 " src="{DATA_URI}"></p>')

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 == "![alt](https://files.example/9)"


def test_each_link_and_image_is_offered_once_an_image_before_its_link() -> None:
html = (
'<p><a href="https://x.example/p" title="t">'
Expand Down Expand Up @@ -402,3 +464,122 @@ def rewrite(link: Link) -> None:
def test_rewrite_link_is_a_public_type() -> None:
callback: RewriteLink = lambda link: None # noqa: E731
assert read("<p>x</p>", rewrite_link=callback) == "x"


# ---------------------------------------------------------------------------
# Which images are raw data, and what they are called
# ---------------------------------------------------------------------------


def test_data_image_ids_lists_ids_in_document_order() -> None:
html = (
f'<p><img id="b" src="{DATA_URI}"><img id="a" src="{DATA_URI}">'
f'<img id="c" src="{DATA_URI}"></p>'
)

assert data_image_ids(html) == ("b", "a", "c")


def test_data_image_ids_drops_duplicates() -> None:
html = (
f'<p><img id="a" src="{DATA_URI}"><img id="b" src="{DATA_URI}">'
f'<img id="a" src="{DATA_URI}"></p>'
)

assert data_image_ids(html) == ("a", "b")


def test_data_image_ids_ignores_images_without_an_id() -> None:
html = (
f'<p><img src="{DATA_URI}"><img id="" src="{DATA_URI}">'
f'<img id=" " src="{DATA_URI}"></p>'
)

assert data_image_ids(html) == ()


def test_data_image_ids_ignores_a_non_data_src() -> None:
html = (
'<p><img id="web" src="https://files.example/i.png">'
f"{PASTED.replace('<p>', '').replace('</p>', '')}"
'<img id="bare"></p>'
)

assert data_image_ids(html) == ()


def test_data_image_ids_sees_a_scheme_in_mixed_case_spaced_and_split() -> None:
html = '<p><img id="t" src=" DaTa\n:image/png;base64,iVBORw0KGgo="></p>'

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'<p><img id="c" src="{lead}data:image/png;base64,AAAA"></p>'

assert data_image_ids(html) == ("c",)


def test_data_image_ids_does_not_strip_a_leading_no_break_space() -> None:
html = '<p><img id="n" src="\xa0data:image/png;base64,AAAA"></p>'

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}><img id="a" src="{DATA_URI}"></{wrapper}>'

assert data_image_ids(html) == ()


def test_data_image_ids_skips_an_image_nested_inside_code() -> None:
html = (
f'<p><code>x<img id="a" src="{DATA_URI}"></code>'
f'<img id="b" src="{DATA_URI}"></p>'
)

assert data_image_ids(html) == ("b",)


@pytest.mark.parametrize("html", ["", "plain text", "<p>no image</p>"])
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,
'<p><img id="t1" src="DATA:image/png;base64,AAAA"></p>',
'<p><img id="t2" src=" data:image/png;base64,AAAA"></p>',
'<p><img id="t3" src="da\nta:image/png;base64,AAAA"></p>',
'<p><img id="t4" src="\tda\tta:image/png;base64,AAAA"></p>',
'<p><img id="t&amp;5" src="data:image/png;base64,AAAA"></p>',
'<p><img id=" t6 " src="data:image/png;base64,AAAA"></p>',
'<p><img id="t7" src="https://files.example/i.png"></p>',
'<p><img id="t8" src="xdata:image/png;base64,AAAA"></p>',
'<p><img id="t9" src="/data:image/png;base64,AAAA"></p>',
'<p><img src="data:image/png;base64,AAAA"></p>',
'<p><a href="data:text/html,x" id="t10"><img id="t11" src="data:x,y"></a></p>',
'<p><img id="d" src="data:a,b"><img id="d" src="data:a,b"></p>',
'<p><img id="c1" src="\x0bdata:image/png;base64,AAAA"></p>',
'<p><img id="c2" src="\x01 data:image/png;base64,AAAA"></p>',
'<p><img id="n1" src="\xa0data:image/png;base64,AAAA"></p>',
'<pre><img id="p1" src="data:image/png;base64,AAAA"></pre>',
'<p><code>x<img id="p2" src="data:image/png;base64,AAAA"></code></p>',
'<p><kbd><img id="p3" src="data:a,b"></kbd>'
'<samp><img id="p4" src="data:a,b"></samp></p>',
],
)
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
Loading
Loading