Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
35e61b1
DVI op iteration interface good enough for completely parsing a simpl…
MaddieM4 Mar 11, 2026
ea3fd90
Make a new Dvi-class-alike based on the Op iterator
MaddieM4 Mar 11, 2026
7347f46
DPI isn't actually needed at the VM level
MaddieM4 Mar 11, 2026
bb93c5d
Only reconsider the baseline_v when we have to
MaddieM4 Mar 11, 2026
a1eff1a
Graduate new implementation to be the primary one
MaddieM4 Mar 12, 2026
e2ec9b2
Restructure to allow multiple dispatch tables
MaddieM4 Mar 12, 2026
92a6f15
Use the new tech for Vf as well
MaddieM4 Mar 12, 2026
5d39b88
Remove old impl
MaddieM4 Mar 12, 2026
7295490
Expose t.color on Text objects
MaddieM4 Mar 12, 2026
767a308
Turns out, boxes need color too, so just do it the same way.
MaddieM4 Mar 12, 2026
44204af
Privatize some parts of the interface
MaddieM4 Mar 12, 2026
e0963da
Print colors of boxes in the dviread module script
MaddieM4 Mar 12, 2026
da42385
Add a release note for new DVI parsing features
MaddieM4 Mar 12, 2026
508ed15
More documentation
MaddieM4 Mar 12, 2026
7debb76
Attempt to address CI errors for rst docs
MaddieM4 Mar 12, 2026
ad8e90a
Fix some stubs and privatize op handlers
MaddieM4 Mar 12, 2026
d37d5ef
Fix the last stub error, assuming that wasn't making mypy error out e…
MaddieM4 Mar 12, 2026
5d380a4
Hopefully allow color tests to run across platforms
MaddieM4 Mar 12, 2026
57f4156
Some doc improvements. Still a bit opaque why docs for certain things…
MaddieM4 Mar 12, 2026
8103dbc
More CI fixes
MaddieM4 Mar 13, 2026
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
Next Next commit
DVI op iteration interface good enough for completely parsing a simpl…
…e example file
  • Loading branch information
MaddieM4 committed Mar 13, 2026
commit 35e61b16adf22c64f79546726b78c137dc9c8181
120 changes: 120 additions & 0 deletions lib/matplotlib/dviread.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,14 @@

import dataclasses
import enum
import io
import logging
import os
import re
import struct
import subprocess
import sys
import typing
from collections import namedtuple
from functools import cache, cached_property, lru_cache, partial, wraps
from pathlib import Path
Expand Down Expand Up @@ -60,6 +62,124 @@

_dvistate = enum.Enum('DviState', 'pre outer inpage post_post finale')

class Ops:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should stay private (at least until there's third-party demand for it being public); ditto for VM. In general we try quite hard to keep as much stuff private as possible, because we don't want to become a general dvi parsing library, and any public API is much more complicated to alter.

"""
Low-level tools for reading a DVI file as a sequence of ops.

This is just using a class for namespacing purposes, don't make instances of it.
Rather, you want to use functions like Ops.read_file, Ops.read_io, etc.
"""
Op = namedtuple('Op', 'code name args')

@classmethod
def read_io(cls, f) -> typing.Generator[Op, None, None]:
while True:
opcode = f.read(1)
if not opcode:
break
opcode = int(opcode[0])
opname, base, atypes, anames = cls._dispatch_table[opcode]
delta = opcode-base
yield cls.Op(opcode, opname, cls._parse_args(f, delta, atypes, anames))
if opname == "unknown":
break

@classmethod
def read_file(cls, filename: str):
with open(filename, "rb") as f:
yield from cls.read_io(f)

@classmethod
def read_bytes(cls, b: bytes):
yield from cls.read_io(io.BytesIO(b))

# Internals
_parsers = {
# r = read_bytes(nbytes, signed)
'delta': lambda r, delta: delta,
'u1': lambda r, delta: r(1, False),
'u2': lambda r, delta: r(2, False),
'u3': lambda r, delta: r(3, False),
'u4': lambda r, delta: r(4, False),
's1': lambda r, delta: r(1, True),
's2': lambda r, delta: r(2, True),
's3': lambda r, delta: r(3, True),
's4': lambda r, delta: r(4, True),
'slen': lambda r, delta: r(delta, True) if delta else None,
'slen1': lambda r, delta: r(delta + 1, True),
'ulen1': lambda r, delta: r(delta + 1, False),
'olen1': lambda r, delta: r(delta + 1, delta == 3),
'fin': lambda r, delta: r(7, False),
}
@classmethod
def _parse_args(cls, f, delta, types, names) -> dict:
result = {}
read_arg = lambda n, s: int.from_bytes(f.read(n), signed=s)
for t, n in zip(types, names):
if t.startswith("@"):
result[n] = f.read(result[t[1:]])
else:
result[n] = cls._parsers[t](read_arg, delta)
return result

def _op(tbl, bmin, bmax, opname, arg_types='', arg_names=''):
arg_types = (' ' + arg_types).split()
arg_names = (' ' + arg_names).split()
entry = (opname, bmin, arg_types, arg_names)
for i in range(bmin, bmax+1):
tbl[i] = entry

_dispatch_table = [('unknown', 0, ['delta'], ['delta'])] * 256
_op = partial(_op, _dispatch_table)

# It's a valid question whether to group ops together under one name,
# or split them apart per code like the docs say. I'm going with the
# grouping approach, but we could theoretically offer both, and we do
# already provide the opcode to consumers.
_op(0, 127, 'set_char', 'delta', 'c')
_op(128, 128, 'set_char', 'u1', 'c')
_op(129, 129, 'set_char', 'u2', 'c')
_op(130, 130, 'set_char', 'u3', 'c')
_op(131, 131, 'set_char', 's4', 'c')
_op(132, 132, 'set_rule', 's4 s4', 'height width')

_op(133, 133, 'put_char', 'u1', 'c')
_op(134, 134, 'put_char', 'u2', 'c')
_op(135, 135, 'put_char', 'u3', 'c')
_op(136, 136, 'put_char', 's4', 'c')
_op(137, 137, 'put_rule', 's4 s4', 'height width')

_op(138, 138, 'nop')
_op(139, 139, 'bop',
"s4 s4 s4 s4 s4 s4 s4 s4 s4 s4 s4",
"c0 c1 c2 c3 c4 c5 c6 c7 c8 c9 p")
_op(140, 140, 'eop')

_op(141, 141, 'push')
_op(142, 142, 'pop')

_op(143, 146, 'right', 'slen1', 'amount')
_op(147, 147, 'w0')
_op(148, 151, 'w', 'slen1', 'new_w')
_op(157, 160, 'down', 'slen1', 'amount')

_op(171, 234, 'fnt_num', 'delta', 'n')

_op(239, 242, 'special', 'ulen1 @k', 'k text')

_op(243, 246, 'fnt_def',
'olen1 u4 s4 u4 u1 u1 @a @l',
'k c s d a l area name')

_op(247, 247, 'pre',
"u1 u4 u4 u4 u1 @k",
"i num den mag k cmnt")
_op(248, 248, 'post',
'u4 u4 u4 u4 u4 u4 u2 u2',
'p num den mag l u s t')
_op(249, 249, 'post_post', 'u4 u1 fin', 'q i padding')


# The marks on a page consist of text and boxes. A page also has dimensions.
Page = namedtuple('Page', 'text boxes height width descent')
Box = namedtuple('Box', 'x y height width')
Expand Down
Binary file not shown.
89 changes: 89 additions & 0 deletions lib/matplotlib/tests/test_dviread.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,97 @@

from matplotlib import cbook, dviread as dr
from matplotlib.testing import subprocess_run_for_testing, _has_tex_package
from matplotlib.texmanager import TexManager
import pytest

def test_ops():
filename = str(Path(__file__).parent / 'baseline_images/dviread/color.dvi')
Op = dr.Ops.Op
set_chars = lambda s: [Op(ord(c), 'set_char', {'c': ord(c)}) for c in s]
assert list(dr.Ops.read_file(filename)) == [
Op(247, 'pre', {
'i': 2, 'num': 25400000, 'den': 473628672, 'mag': 1000, 'k': 27,
'cmnt': b' TeX output 2026.03.10:0955'}),
Op(139, 'bop', {
'c0': 1, 'c1': 0, 'c2': 0, 'c3': 0, 'c4': 0, 'c5': 0,
'c6': 0, 'c7': 0, 'c8': 0, 'c9': 0, 'p': -1}),
Op(141, 'push', {}),
Op(239, 'special', {'k': 26, 'text': b'header=l3backend-dvips.pro'}),
Op(239, 'special', {'k': 35, 'text': b'papersize=5203.43999pt,5203.43999pt'}),
Op(239, 'special', {'k': 35, 'text': b'papersize=5203.43999pt,5203.43999pt'}),
Op(142, 'pop', {}),
Op(160, 'down', {'amount': 333506151}),
Op(141, 'push', {}),
Op(160, 'down', {'amount': -335144551}),
Op(141, 'push', {}),
Op(141, 'push', {}),
Op(239, 'special', {'k': 17, 'text': b'color push Black'}),
Op(146, 'right', {'amount': 331540071}),
Op(239, 'special', {'k': 9, 'text': b'color pop'}),
Op(142, 'pop', {}),
Op(142, 'pop', {}),
Op(160, 'down', {'amount': 333178471}),
Op(141, 'push', {}),
Op(160, 'down', {'amount': -330098279}),
Op(141, 'push', {}),
Op(145, 'right', {'amount': 983040}),
Op(243, 'fnt_def', {
'k': 28, 'c': 2194559542, 's': 786432, 'd': 786432, 'a': 0, 'l': 6,
'area': b'', 'name': b'cmss12'}),
Op(199, 'fnt_num', {'n': 28}),
] + set_chars("Default,") + [
Op(145, 'right', {'amount': 475130}),
Op(239, 'special', {'k': 28, 'text': b'color push rgb 1.0 0.0 0.0'}),
] + set_chars("red") + [
Op(144, 'right', {'amount': 20984}),
Op(141, 'push', {}),
Op(141, 'push', {}),
Op(159, 'down', {'amount': -174762}),
Op(132, 'set_rule', {'height': 65536, 'width': 65536}),
Op(142, 'pop', {}),
Op(142, 'pop', {}),
Op(150, 'w', args={'new_w': 65536}),
Op(141, 'push', {}),
Op(141, 'push', {}),
Op(159, 'down', {'amount': -174762}),
Op(132, 'set_rule', {'height': 65536, 'width': 65536}),
Op(142, 'pop', {}),
Op(142, 'pop', {}),
] + ([
# The red line is apparently a bunch of little red lines.
Op(147, 'w0', {}),
Op(141, 'push', {}),
Op(141, 'push', {}),
Op(159, 'down', {'amount': -174762}),
Op(132, 'set_rule', {'height': 65536, 'width': 65536}),
Op(142, 'pop', {}),
Op(142, 'pop', {}),
] * 83) + [
Op(145, 'right', {'amount': 68031}),
Op(239, 'special', {'k': 9, 'text': b'color pop'}),
] + set_chars(",and") + [
Op(150, 'w', args={'new_w': 256680}),
] + set_chars("back") + [
Op(147, 'w0', args={}),
] + set_chars("again.") + [
Op(142, 'pop', {}),
Op(142, 'pop', {}),
Op(159, 'down', {'amount': 1966080}),
Op(141, 'push', {}),
Op(239, 'special', {'k': 17, 'text': b'color push Black'}),
Op(146, 'right', {'amount': 331540071}),
Op(239, 'special', {'k': 9, 'text': b'color pop'}),
Op(142, 'pop', {}),
Op(142, 'pop', {}),
Op(140, 'eop', {}),
Op(248, 'post', {
'p': 42, 'num': 25400000, 'den': 473628672, 'mag': 1000,
'l': 333506151, 'u': 331540071, 's': 5, 't': 1}),
Op(243, 'fnt_def', {
'k': 28, 'c': 2194559542, 's': 786432, 'd': 786432, 'a': 0, 'l': 6,
'area': b'', 'name': b'cmss12'}),
Op(249, 'post_post', {'q': 1939, 'i': 2, 'padding': 3755991007}),
]

def test_PsfontsMap(monkeypatch):
monkeypatch.setattr(dr, 'find_tex_file', lambda x: x.decode())
Expand Down