feat: add image plugin for jpeg, png and gif comments and tag svg files

This commit is contained in:
randogoth 2026-10-25 10:24:21 +02:00
parent 0a19339b98
commit 712fe264be
10 changed files with 341 additions and 43 deletions

View file

@ -90,3 +90,15 @@ def test_marker_inside_code_is_ignored(cwd):
(cwd / 'a.py').write_text('x = "otag: #nope"\n')
OrvaniTag().scan('.', False)
assert not (cwd / orvani.CONTEXT_FILE).exists()
def test_svg_keeps_xml_declaration_first(cwd):
write('a.svg', '<?xml version="1.0"?>\n<svg xmlns="http://www.w3.org/2000/svg"/>\n', '#icon')
assert (cwd / 'a.svg').read_text() == '<?xml version="1.0"?>\n<!-- otag: #icon -->\n\n<svg xmlns="http://www.w3.org/2000/svg"/>\n'
assert read_back(cwd, 'a.svg') == 'a.svg --- #icon\n'
def test_double_dash_is_refused_in_markup_comments(cwd):
with pytest.raises(UnsupportedFile):
write('a.svg', '<svg/>\n', '#icon', 'before -- after')
assert (cwd / 'a.svg').read_text() == '<svg/>\n'

132
tests/test_image.py Normal file
View file

@ -0,0 +1,132 @@
import struct
import zlib
import pytest
import orvanitag.orvani as orvani
from orvanitag.entry import Entry
from orvanitag.orvani import OrvaniTag
from orvanitag.errors import UnsupportedFile
@pytest.fixture
def cwd(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
return tmp_path
def roundtrip(cwd, name, *tags):
"""Writes the tags and reads them back into a fresh entry"""
OrvaniTag().add(name, *tags)
OrvaniTag().write_metadata(name, 'image')
expected = (cwd / orvani.CONTEXT_FILE).read_text()
(cwd / orvani.CONTEXT_FILE).unlink()
OrvaniTag().read_metadata(name)
assert (cwd / orvani.CONTEXT_FILE).read_text() == expected
def clear(name):
OrvaniTag().write_metadata(name, 'image', clear=True)
def segment(marker, data):
return bytes([0xFF, marker]) + (len(data) + 2).to_bytes(2, 'big') + data
SOI = b'\xff\xd8'
APPS = segment(0xE0, b'JFIF\0\1\1\0\0\1\0\1\0\0') + segment(0xE1, b'Exif\0\0')
REST = segment(0xDB, bytes(65)) + segment(0xDA, bytes(10)) + b'\x12\x34\xff\xd9'
def test_jpeg_comment_goes_after_the_app_segments(cwd):
(cwd / 'a.jpg').write_bytes(SOI + APPS + REST)
roundtrip(cwd, 'a.jpg', '+trip', '#beach', 'sunset')
assert (cwd / 'a.jpg').read_bytes() == SOI + APPS + segment(0xFE, b'otag: +trip #beach sunset') + REST
clear('a.jpg')
assert (cwd / 'a.jpg').read_bytes() == SOI + APPS + REST
def test_jpeg_keeps_other_comments(cwd):
foreign = segment(0xFE, b'CREATOR: gd-jpeg v1.0')
(cwd / 'a.JPEG').write_bytes(SOI + APPS + foreign + REST)
assert OrvaniTag().read_metadata('a.JPEG') == Entry()
roundtrip(cwd, 'a.JPEG', '#beach')
OrvaniTag().add('a.JPEG', '#more')
OrvaniTag().write_metadata('a.JPEG', 'image')
assert (cwd / 'a.JPEG').read_bytes() == SOI + APPS + segment(0xFE, b'otag: #beach #more') + foreign + REST
clear('a.JPEG')
assert (cwd / 'a.JPEG').read_bytes() == SOI + APPS + foreign + REST
def test_jpeg_comment_length_is_limited(cwd):
(cwd / 'a.jpg').write_bytes(SOI + APPS + REST)
OrvaniTag().add('a.jpg', 'x' * 0x10000)
with pytest.raises(UnsupportedFile, match='too long'):
OrvaniTag().write_metadata('a.jpg', 'image')
assert (cwd / 'a.jpg').read_bytes() == SOI + APPS + REST
def chunk(kind, data):
return len(data).to_bytes(4, 'big') + kind + data + zlib.crc32(kind + data).to_bytes(4, 'big')
def chunks(data):
"""The chunks of a PNG, checking their CRCs"""
found, pos = [], 8
while pos < len(data):
size, kind = int.from_bytes(data[pos:pos + 4], 'big'), data[pos + 4:pos + 8]
body = data[pos + 8:pos + 8 + size]
assert data[pos + 8 + size:pos + 12 + size] == zlib.crc32(kind + body).to_bytes(4, 'big')
found.append((kind, body))
pos += 12 + size
return found
PNG = b'\x89PNG\r\n\x1a\n'
IHDR = chunk(b'IHDR', struct.pack('>IIBBBBB', 1, 1, 8, 0, 0, 0, 0))
IMAGE = chunk(b'IDAT', zlib.compress(b'\0\0')) + chunk(b'IEND', b'')
def test_png_comment_chunk_and_others_kept(cwd):
foreign = chunk(b'iTXt', b'Comment\0\0\0\0\0Created with GIMP') + chunk(b'tEXt', b'Software\0x')
(cwd / 'a.png').write_bytes(PNG + IHDR + foreign + IMAGE)
assert OrvaniTag().read_metadata('a.png') == Entry()
roundtrip(cwd, 'a.png', '#фото', 'a note')
data = (cwd / 'a.png').read_bytes()
assert chunks(data)[1] == (b'iTXt', b'Comment\0\0\0\0\0' + 'otag: #фото a note'.encode())
assert data == PNG + IHDR + chunk(b'iTXt', b'Comment\0\0\0\0\0' + 'otag: #фото a note'.encode()) + foreign + IMAGE
clear('a.png')
assert (cwd / 'a.png').read_bytes() == PNG + IHDR + foreign + IMAGE
GIF_SCREEN = b'\x01\x00\x01\x00\x80\x00\x00' + b'\xff\xff\xff\x00\x00\x00'
GIF_IMAGE = b',\x00\x00\x00\x00\x01\x00\x01\x00\x00\x02\x02\x44\x01\x00;'
def test_gif_comment_in_sub_blocks(cwd):
foreign = b'\x21\xfe\x07made by\x00'
(cwd / 'a.gif').write_bytes(b'GIF89a' + GIF_SCREEN + foreign + GIF_IMAGE)
note = 'n' * 300
roundtrip(cwd, 'a.gif', '#anim', note)
line = f'otag: #anim {note}'.encode()
assert (cwd / 'a.gif').read_bytes() == (b'GIF89a' + GIF_SCREEN + b'\x21\xfe\xff' + line[:255]
+ bytes([len(line) - 255]) + line[255:] + b'\0' + foreign + GIF_IMAGE)
clear('a.gif')
assert (cwd / 'a.gif').read_bytes() == b'GIF89a' + GIF_SCREEN + foreign + GIF_IMAGE
def test_gif87a_becomes_89a(cwd):
(cwd / 'a.gif').write_bytes(b'GIF87a' + GIF_SCREEN + GIF_IMAGE)
roundtrip(cwd, 'a.gif', '#old')
assert (cwd / 'a.gif').read_bytes() == b'GIF89a' + GIF_SCREEN + b'\x21\xfe' + bytes([10]) + b'otag: #old\0' + GIF_IMAGE
@pytest.mark.parametrize('name', ['a.jpg', 'a.png', 'a.gif'])
def test_other_files_are_refused_unchanged(cwd, name):
(cwd / name).write_bytes(b'plain text')
OrvaniTag().add(name, '#lofi')
with pytest.raises(UnsupportedFile):
OrvaniTag().write_metadata(name, 'image')
assert (cwd / name).read_bytes() == b'plain text'