feat(magazine): fit text boxes to their ink in Clean up boxes
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
91caa6cf90
commit
f4892dbde4
@ -12,6 +12,11 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi
|
|||||||
- **Pronunciation & spelling panel (per article).** Acronyms are spoken from the article's language (decided once per article, with per-block override for quotations) using an extended built-in list, letter-by-letter spelling for `USB`/`HDMI`-style acronyms, and an editable per-article glossary. Unfamiliar acronyms are listed with their sentence, and the language model can decide expansion / letters / word from context.
|
- **Pronunciation & spelling panel (per article).** Acronyms are spoken from the article's language (decided once per article, with per-block override for quotations) using an extended built-in list, letter-by-letter spelling for `USB`/`HDMI`-style acronyms, and an editable per-article glossary. Unfamiliar acronyms are listed with their sentence, and the language model can decide expansion / letters / word from context.
|
||||||
- **German and English spellchecker with fix-or-mark.** Unknown words are underlined in the review list and listed with suggestions per page and per article; click a suggestion (undoable), or *Keep* to add the word to the issue's dictionary. *Fix with language model* corrects only what passes strict guards (a dictionary suggestion, or a known word within two edits) and keeps names/terms; the whole-magazine quality check now runs the same pass and reports pages with words still marked.
|
- **German and English spellchecker with fix-or-mark.** Unknown words are underlined in the review list and listed with suggestions per page and per article; click a suggestion (undoable), or *Keep* to add the word to the issue's dictionary. *Fix with language model* corrects only what passes strict guards (a dictionary suggestion, or a known word within two edits) and keeps names/terms; the whole-magazine quality check now runs the same pass and reports pages with words still marked.
|
||||||
|
|
||||||
|
- **Clean up boxes now also fits text boxes to their text.** A side that cuts through letters grows (at most a letter or two, never into a neighbouring box) and empty margins are trimmed. Boxes you drew or resized by hand are never touched.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Suspended compounds (`Programmier- und Hausaufgaben`) no longer lose their hyphen when regions are merged or layouts are grouped; already-ingested `Programmierund` shows in the spelling list with a one-click repair.
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
- `prepare_text_for_tts` accepts the article language and glossary; narration no longer guesses the language per fragment.
|
- `prepare_text_for_tts` accepts the article language and glossary; narration no longer guesses the language per fragment.
|
||||||
|
|
||||||
|
|||||||
@ -282,6 +282,10 @@ the original for anything that matters.
|
|||||||
Podcast generation is refused for an article you have not approved, because
|
Podcast generation is refused for an article you have not approved, because
|
||||||
unreviewed boundaries may include a neighbouring article or an advert.
|
unreviewed boundaries may include a neighbouring article or an advert.
|
||||||
|
|
||||||
|
### Clean up boxes: fitting boxes to the text
|
||||||
|
|
||||||
|
**Clean up boxes** (review toolbar) also fits each text box to the ink of its text: a side that cuts through letters grows a little, empty margin is trimmed. Boxes you drew or resized are never changed; a backup of the layout is kept and the message reports how many boxes were fitted.
|
||||||
|
|
||||||
### One paragraph, one block — even across columns and pages
|
### One paragraph, one block — even across columns and pages
|
||||||
|
|
||||||
**Purpose:** a paragraph that continues into the next column or onto a later page is separate regions in the layout. Reading and the MP3 join them before splitting into sentences, so narration never stops mid-sentence at a seam.
|
**Purpose:** a paragraph that continues into the next column or onto a later page is separate regions in the layout. Reading and the MP3 join them before splitting into sentences, so narration never stops mid-sentence at a seam.
|
||||||
|
|||||||
2
TODO.md
2
TODO.md
@ -131,7 +131,7 @@ marked on the page for a human. Order of work:
|
|||||||
read, or type a number into each one. The order drives playback and the OCR-results list.
|
read, or type a number into each one. The order drives playback and the OCR-results list.
|
||||||
Today the order is whatever the layout analysis produced, and a page with two text columns
|
Today the order is whatever the layout analysis produced, and a page with two text columns
|
||||||
comes out interleaved, so a listener hears half a sentence from the wrong column.
|
comes out interleaved, so a listener hears half a sentence from the wrong column.
|
||||||
- [ ] **L — Magazine: overhaul region boxes and how they are handled in OCR and approval.** (Done 2026-09-21: type picker and delete per row; Clean up boxes — text in pictures, OCR noise, duplicates, run-on author lines. Done too: keyboard navigation, merge with next, split at cursor, per-row re-OCR and text editing. Still open: snapping text boxes to the ink of their text; the 333 overlapping boxes with different text on the measured issue need a human.)
|
- [ ] **L — Magazine: overhaul region boxes and how they are handled in OCR and approval.** (Done 2026-09-21: type picker and delete per row; Clean up boxes — text in pictures, OCR noise, duplicates, run-on author lines. Done too: keyboard navigation, merge with next, split at cursor, per-row re-OCR and text editing. Done 2026-09-28: *Clean up boxes* now fits text boxes to their ink (grows over clipped edges, trims empty margins; hand-placed boxes untouched; on the measured issue only 5 of 658 boxes on pages 1-59 needed it). Still open: the 333 overlapping boxes with different text on the measured issue need a human.)
|
||||||
The boxes the layout analysis produces need a pass of their own, on top of the ordering work
|
The boxes the layout analysis produces need a pass of their own, on top of the ordering work
|
||||||
above. Seen on real pages: a byline box that runs on into the first body line ("Von Hartmut
|
above. Seen on real pages: a byline box that runs on into the first body line ("Von Hartmut
|
||||||
Gieselmann Ein guter Butler kennt die Vorlieben seines"), so author and paragraph both come
|
Gieselmann Ein guter Butler kennt die Vorlieben seines"), so author and paragraph both come
|
||||||
|
|||||||
115
core/magazine.py
115
core/magazine.py
@ -2892,6 +2892,120 @@ def _clipped_edges(pixels, page_meta: dict[str, Any], text_blocks: list[dict[str
|
|||||||
return found
|
return found
|
||||||
|
|
||||||
|
|
||||||
|
_SNAP_TEXT_STEP_PX = 2 # grow/shrink granularity
|
||||||
|
_SNAP_TEXT_MAX_GROW_PX = 16 # never grow a side further than about a letter or two
|
||||||
|
_SNAP_TEXT_PAD_PX = 2 # air left between the text and its snapped edge
|
||||||
|
_SNAP_TEXT_MIN_PX = 8 # a snapped box keeps at least this size
|
||||||
|
_SNAP_TEXT_MIN_MOVE_PX = 3 # smaller corrections are not worth changing a box for
|
||||||
|
_SNAP_TEXT_LINE_INK = 0.004 # a pixel line with less ink than this share is empty margin
|
||||||
|
|
||||||
|
|
||||||
|
def snap_text_boxes(doc_dir: Path, layout: dict[str, Any],
|
||||||
|
pages: Iterable[int] | None = None) -> tuple[dict[str, Any], list[dict[str, Any]]]:
|
||||||
|
"""Fit each text box to the ink of its own text, using the page render.
|
||||||
|
|
||||||
|
Two corrections, both bounded: a side that cuts through letters (ink continues just outside
|
||||||
|
it, where no other box is) grows until the text ends, by at most a letter or two; empty
|
||||||
|
margin inside a side is trimmed back to the first line with ink, leaving a little air. A
|
||||||
|
box the reviewer drew or resized by hand is never touched, and neither is one that no page
|
||||||
|
render exists for. Geometry only -- the text is not changed. Returns the layout and one
|
||||||
|
report row per box that moved.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
import numpy as np
|
||||||
|
except Exception:
|
||||||
|
return layout, []
|
||||||
|
wanted = set(pages) if pages is not None else None
|
||||||
|
snappable = set(NARRATABLE) | {BLOCK_CAPTION, BLOCK_SIDEBAR, BLOCK_PULLQUOTE}
|
||||||
|
layout = dict(layout)
|
||||||
|
blocks = [dict(b) for b in layout.get("blocks", [])]
|
||||||
|
moved: list[dict[str, Any]] = []
|
||||||
|
for meta in layout.get("pages", []):
|
||||||
|
page_no = meta["page"]
|
||||||
|
if wanted is not None and page_no not in wanted:
|
||||||
|
continue
|
||||||
|
png = doc_dir / "pages" / f"{page_no:04d}.png"
|
||||||
|
if not png.is_file():
|
||||||
|
continue
|
||||||
|
pixels = page_ink_mask(png)
|
||||||
|
if pixels is None:
|
||||||
|
continue
|
||||||
|
height, width = pixels.shape
|
||||||
|
sx, sy = width / max(1.0, float(meta["width"])), height / max(1.0, float(meta["height"]))
|
||||||
|
on_page = [b for b in blocks if b["page"] == page_no]
|
||||||
|
images = [i for i in layout.get("images", []) if i["page"] == page_no]
|
||||||
|
|
||||||
|
def to_px(bbox: Sequence[float]) -> list[int]:
|
||||||
|
x, y, w, h = bbox
|
||||||
|
return [max(0, int(round(x * sx))), max(0, int(round(y * sy))),
|
||||||
|
min(width, int(round((x + w) * sx))), min(height, int(round((y + h) * sy)))]
|
||||||
|
|
||||||
|
for block in on_page:
|
||||||
|
if (block["type"] not in snappable or block.get("source") == "manual"
|
||||||
|
or block.get("box_manual") or block.get("region_label")):
|
||||||
|
continue
|
||||||
|
left, top, right, bottom = to_px(block["bbox"])
|
||||||
|
if right - left < _SNAP_TEXT_MIN_PX or bottom - top < _SNAP_TEXT_MIN_PX:
|
||||||
|
continue
|
||||||
|
others = np.zeros_like(pixels)
|
||||||
|
for other in on_page:
|
||||||
|
if other is not block:
|
||||||
|
ol, ot, orr, ob = to_px(other["bbox"])
|
||||||
|
others[ot:ob, ol:orr] = True
|
||||||
|
for image in images:
|
||||||
|
il, it, ir, ib = to_px(image["bbox"])
|
||||||
|
others[it:ib, il:ir] = True
|
||||||
|
original = (left, top, right, bottom)
|
||||||
|
|
||||||
|
def strip_has_text(r0: int, r1: int, c0: int, c1: int) -> bool:
|
||||||
|
r0, c0, r1, c1 = max(0, r0), max(0, c0), min(height, r1), min(width, c1)
|
||||||
|
if r1 <= r0 or c1 <= c0:
|
||||||
|
return False
|
||||||
|
free = ~others[r0:r1, c0:c1]
|
||||||
|
return bool(free.sum() >= 12 and (pixels[r0:r1, c0:c1] & free).sum() / free.sum() >= _AUDIT_CLIP_INK)
|
||||||
|
|
||||||
|
step = _SNAP_TEXT_STEP_PX
|
||||||
|
for _ in range(_SNAP_TEXT_MAX_GROW_PX // step):
|
||||||
|
if strip_has_text(top, bottom, left - step, left) and left - step >= 0:
|
||||||
|
left -= step
|
||||||
|
elif strip_has_text(top, bottom, right, right + step) and right + step <= width:
|
||||||
|
right += step
|
||||||
|
elif strip_has_text(top - step, top, left, right) and top - step >= 0:
|
||||||
|
top -= step
|
||||||
|
elif strip_has_text(bottom, bottom + step, left, right) and bottom + step <= height:
|
||||||
|
bottom += step
|
||||||
|
else:
|
||||||
|
break
|
||||||
|
if (left, top, right, bottom) == original:
|
||||||
|
# Not clipped: trim the empty margins instead. A line is empty margin when it
|
||||||
|
# holds (almost) no ink of its own.
|
||||||
|
def line_empty(r0: int, r1: int, c0: int, c1: int) -> bool:
|
||||||
|
area = max(1, (r1 - r0) * (c1 - c0))
|
||||||
|
return (pixels[r0:r1, c0:c1].sum() / area) <= _SNAP_TEXT_LINE_INK
|
||||||
|
|
||||||
|
while right - left - step >= _SNAP_TEXT_MIN_PX and line_empty(top, bottom, left, left + step):
|
||||||
|
left += step
|
||||||
|
while right - left - step >= _SNAP_TEXT_MIN_PX and line_empty(top, bottom, right - step, right):
|
||||||
|
right -= step
|
||||||
|
while bottom - top - step >= _SNAP_TEXT_MIN_PX and line_empty(top, top + step, left, right):
|
||||||
|
top += step
|
||||||
|
while bottom - top - step >= _SNAP_TEXT_MIN_PX and line_empty(bottom - step, bottom, left, right):
|
||||||
|
bottom -= step
|
||||||
|
if (left, top, right, bottom) != original:
|
||||||
|
left, top = max(0, left - _SNAP_TEXT_PAD_PX), max(0, top - _SNAP_TEXT_PAD_PX)
|
||||||
|
right, bottom = min(width, right + _SNAP_TEXT_PAD_PX), min(height, bottom + _SNAP_TEXT_PAD_PX)
|
||||||
|
moves = [abs(a - b) for a, b in zip((left, top, right, bottom), original)]
|
||||||
|
if max(moves) < _SNAP_TEXT_MIN_MOVE_PX:
|
||||||
|
continue
|
||||||
|
block["bbox"] = [round(left / sx, 1), round(top / sy, 1),
|
||||||
|
round((right - left) / sx, 1), round((bottom - top) / sy, 1)]
|
||||||
|
block["box_snapped"] = True
|
||||||
|
moved.append({"page": page_no, "id": block["id"],
|
||||||
|
"grew": (left, top) < original[:2] or (right, bottom) > original[2:]})
|
||||||
|
layout["blocks"] = blocks
|
||||||
|
return layout, moved
|
||||||
|
|
||||||
|
|
||||||
def duplicate_article_groups(articles: Sequence[dict[str, Any]]) -> list[list[str]]:
|
def duplicate_article_groups(articles: Sequence[dict[str, Any]]) -> list[list[str]]:
|
||||||
"""Groups of articles that share most of their text regions -- the same words would be
|
"""Groups of articles that share most of their text regions -- the same words would be
|
||||||
narrated once per article. Two articles are grouped when the smaller one shares at least
|
narrated once per article. Two articles are grouped when the smaller one shares at least
|
||||||
@ -3492,6 +3606,7 @@ def update_region(doc_dir: Path, layout: dict[str, Any], page_no: int, block_id:
|
|||||||
old_text = str(block.get("text") or "")
|
old_text = str(block.get("text") or "")
|
||||||
if bbox is not None:
|
if bbox is not None:
|
||||||
block["bbox"] = list(bbox)
|
block["bbox"] = list(bbox)
|
||||||
|
block["box_manual"] = True # placed by hand: snap_text_boxes leaves it alone
|
||||||
new_text = None
|
new_text = None
|
||||||
if reocr:
|
if reocr:
|
||||||
new_text = extract_region_text(
|
new_text = extract_region_text(
|
||||||
|
|||||||
@ -1327,6 +1327,9 @@ async def magazine_clean_up_boxes(doc_id: str):
|
|||||||
backup = d / f"layout.backup-{time.strftime('%Y%m%dT%H%M%S')}.json"
|
backup = d / f"layout.backup-{time.strftime('%Y%m%dT%H%M%S')}.json"
|
||||||
backup.write_text(json.dumps(layout, ensure_ascii=False), "utf-8")
|
backup.write_text(json.dumps(layout, ensure_ascii=False), "utf-8")
|
||||||
layout, report = await asyncio.to_thread(mag.clean_up_regions, layout)
|
layout, report = await asyncio.to_thread(mag.clean_up_regions, layout)
|
||||||
|
# Last, so the boxes are fitted after the rules above have settled which ones remain.
|
||||||
|
layout, snapped = await asyncio.to_thread(mag.snap_text_boxes, d, layout)
|
||||||
|
report["snapped"] = snapped
|
||||||
mag.save_layout(d, layout)
|
mag.save_layout(d, layout)
|
||||||
articles = mag.load_articles(d)
|
articles = mag.load_articles(d)
|
||||||
if _add_split_paragraphs_to_articles(articles, report):
|
if _add_split_paragraphs_to_articles(articles, report):
|
||||||
|
|||||||
@ -2398,7 +2398,7 @@ async function magReviewSubmitEdit(reocr) {
|
|||||||
async function magCleanUpBoxes() {
|
async function magCleanUpBoxes() {
|
||||||
magOfferUndo(null); // this change records no undo point, so an older Undo no longer applies
|
magOfferUndo(null); // this change records no undo point, so an older Undo no longer applies
|
||||||
const ok = await confirmDialog('Clean up the boxes of this whole issue: text inside pictures, OCR noise (boxes of one or two characters) ' +
|
const ok = await confirmDialog('Clean up the boxes of this whole issue: text inside pictures, OCR noise (boxes of one or two characters) ' +
|
||||||
'and boxes that repeat a bigger box\'s text stop being read aloud, and author lines that run into the first sentence are split. ' +
|
'and boxes that repeat a bigger box\'s text stop being read aloud, author lines that run into the first sentence are split, and text boxes are fitted to the ink of their text (boxes you drew or resized yourself are left alone). ' +
|
||||||
'Nothing is deleted — every change shows in the review list and can be undone with the type picker — and a backup of the layout is kept.',
|
'Nothing is deleted — every change shows in the review list and can be undone with the type picker — and a backup of the layout is kept.',
|
||||||
{ title: 'Clean up boxes?', okLabel: 'Clean up' });
|
{ title: 'Clean up boxes?', okLabel: 'Clean up' });
|
||||||
if (!ok) return;
|
if (!ok) return;
|
||||||
@ -2412,7 +2412,7 @@ async function magCleanUpBoxes() {
|
|||||||
return say('Could not clean up the boxes: ' + e.message, 'err');
|
return say('Could not clean up the boxes: ' + e.message, 'err');
|
||||||
}
|
}
|
||||||
const c = r.counts || {};
|
const c = r.counts || {};
|
||||||
const parts = [[c.in_picture, 'text in pictures'], [c.ocr_noise, 'OCR noise'], [c.duplicate, 'duplicates'], [c.byline_split, 'author lines split']]
|
const parts = [[c.in_picture, 'text in pictures'], [c.ocr_noise, 'OCR noise'], [c.duplicate, 'duplicates'], [c.byline_split, 'author lines split'], [c.snapped, 'boxes fitted to their text']]
|
||||||
.filter(([n]) => n).map(([n, what]) => n + ' ' + what);
|
.filter(([n]) => n).map(([n, what]) => n + ' ' + what);
|
||||||
say(parts.length ? 'Cleaned up: ' + parts.join(', ') + '. Backup: ' + r.backup : 'Nothing to clean up.', 'ok');
|
say(parts.length ? 'Cleaned up: ' + parts.join(', ') + '. Backup: ' + r.backup : 'Nothing to clean up.', 'ok');
|
||||||
state.layout = null;
|
state.layout = null;
|
||||||
|
|||||||
@ -2674,3 +2674,72 @@ class SuspendedCompoundSurvivesRegionEditsTests(unittest.TestCase):
|
|||||||
{"id": "b", "page": 1, "type": "body", "bbox": [0, 10, 10, 10], "text": "tert", "reading_order": 1}]}
|
{"id": "b", "page": 1, "type": "body", "bbox": [0, 10, 10, 10], "text": "tert", "reading_order": 1}]}
|
||||||
out, _ = merge_with_next(layout, 1, "a")
|
out, _ = merge_with_next(layout, 1, "a")
|
||||||
self.assertEqual(out["blocks"][0]["text"], "eingetrichtert")
|
self.assertEqual(out["blocks"][0]["text"], "eingetrichtert")
|
||||||
|
|
||||||
|
|
||||||
|
class SnapTextBoxesTests(unittest.TestCase):
|
||||||
|
"""A 600x800 white page (1 px = 1 point) with solid 'text' blocks of known extent."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
from pathlib import Path
|
||||||
|
from PIL import Image, ImageDraw
|
||||||
|
self.dir = Path(tempfile.mkdtemp(prefix="ttvc-snap-"))
|
||||||
|
(self.dir / "pages").mkdir()
|
||||||
|
im = Image.new("RGB", (600, 800), "white")
|
||||||
|
draw = ImageDraw.Draw(im)
|
||||||
|
for y in range(100, 200, 10): # ten 'lines' of ink: x 100..300, y 100..200
|
||||||
|
draw.rectangle([100, y, 300, y + 5], fill="black")
|
||||||
|
for y in range(100, 200, 10): # a neighbouring column: x 320..500
|
||||||
|
draw.rectangle([320, y, 500, y + 5], fill="black")
|
||||||
|
im.save(self.dir / "pages" / "0001.png")
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
shutil.rmtree(self.dir, ignore_errors=True)
|
||||||
|
|
||||||
|
def layout(self, boxes):
|
||||||
|
return {"pages": [{"page": 1, "width": 600, "height": 800}], "images": [],
|
||||||
|
"blocks": [{"id": i, "page": 1, "type": "body", "text": "t", "bbox": bb, "reading_order": n, **extra}
|
||||||
|
for n, (i, bb, extra) in enumerate(boxes)]}
|
||||||
|
|
||||||
|
def snap(self, boxes):
|
||||||
|
from core.magazine import snap_text_boxes
|
||||||
|
layout, moved = snap_text_boxes(self.dir, self.layout(boxes))
|
||||||
|
return {b["id"]: b["bbox"] for b in layout["blocks"]}, moved, layout
|
||||||
|
|
||||||
|
def test_empty_margin_is_trimmed_to_the_text_with_a_little_air(self):
|
||||||
|
got, moved, _ = self.snap([("a", [60, 60, 250, 200], {})]) # box 60..310 x 60..260 around the text
|
||||||
|
x, y, w, h = got["a"]
|
||||||
|
self.assertTrue(96 <= x <= 100 and 96 <= y <= 100, got)
|
||||||
|
self.assertTrue(300 <= x + w <= 306 and 196 <= y + h <= 206, got)
|
||||||
|
self.assertEqual(len(moved), 1)
|
||||||
|
|
||||||
|
def test_a_side_that_cuts_through_the_text_grows_until_the_text_ends(self):
|
||||||
|
got, moved, _ = self.snap([("a", [100, 100, 190, 100], {})]) # right edge at 290, text runs to 300
|
||||||
|
x, y, w, h = got["a"]
|
||||||
|
self.assertGreaterEqual(x + w, 298)
|
||||||
|
self.assertLessEqual(x + w, 306)
|
||||||
|
self.assertTrue(moved[0]["grew"])
|
||||||
|
|
||||||
|
def test_growth_never_enters_a_neighbouring_box(self):
|
||||||
|
got, _, _ = self.snap([("a", [100, 100, 190, 100], {}), ("b", [296, 90, 220, 120], {})])
|
||||||
|
self.assertLessEqual(got["a"][0] + got["a"][2], 300)
|
||||||
|
|
||||||
|
def test_boxes_placed_by_hand_and_labelled_regions_are_left_alone(self):
|
||||||
|
boxes = [("m", [60, 60, 250, 200], {"source": "manual"}), ("e", [60, 60, 250, 200], {"box_manual": True}),
|
||||||
|
("l", [60, 60, 250, 200], {"region_label": "in_picture"})]
|
||||||
|
got, moved, _ = self.snap(boxes)
|
||||||
|
self.assertEqual(moved, [])
|
||||||
|
self.assertEqual(got["m"], [60, 60, 250, 200])
|
||||||
|
|
||||||
|
def test_a_box_already_fitted_does_not_move_again_and_text_is_untouched(self):
|
||||||
|
first, _, layout = self.snap([("a", [60, 60, 250, 200], {})])
|
||||||
|
from core.magazine import snap_text_boxes
|
||||||
|
again, moved = snap_text_boxes(self.dir, layout)
|
||||||
|
self.assertEqual(moved, [])
|
||||||
|
self.assertEqual([b["text"] for b in again["blocks"]], ["t"])
|
||||||
|
|
||||||
|
def test_a_page_without_a_render_or_a_picture_type_is_skipped(self):
|
||||||
|
from core.magazine import snap_text_boxes
|
||||||
|
layout = self.layout([("f", [60, 60, 250, 200], {"type": "figure"})])
|
||||||
|
self.assertEqual(snap_text_boxes(self.dir, layout)[1], [])
|
||||||
|
(self.dir / "pages" / "0001.png").unlink()
|
||||||
|
self.assertEqual(snap_text_boxes(self.dir, self.layout([("a", [60, 60, 250, 200], {})]))[1], [])
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user