0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

How I Cut YouTube Slide Production from 20 Hours to 3 ― An xlsx-to-SVG Compositor Built in Python

0
Posted at

The real bottleneck wasn't the prediction logic — it was making slides. This is the record of how a self-built xlsx-to-SVG compositor cut per-video slide production time from 20 hours to 3.

Introduction

I've been building a self-taught Python horse racing prediction system (V5py / V58py) as a hobby project for about six months to a year, and publishing the weekly results as videos on my YouTube channel, "71-year-old Retiree's Horse Racing Gazette."

The videos themselves are just commentary comparing scores, past performance, and market odds. But it turned out the biggest time sink in the whole production pipeline wasn't the prediction logic — it was making the slides. This is the record of how I cut "34 slides, 20 hours per video" down to 3 hours, covering the first half of July 2026 (7/1–7/11), bugs and design detours included.

I built four tools for this:

Tool Role
スライド画像加工.py (slide_image_processor.py) Auto-composites xlsx result tables into SVG slides (main tool)
umabasira_HTML_bunnkatu.py Splits a race-card HTML page into one PNG per horse
horse_single_tool.py Regenerates a single horse's PNG individually
bashita_tool.py Fetches and saves race information pages

Starting point: manual work in Krita

As of 7/1, the workflow was: paste three tables (ranking, score breakdown, betting suggestions) into Krita by hand and align their widths using free transform (Ctrl+T). Since these were raster images, scaling them distorted the text, so every alignment pass required a visual check for distortion. Assembling one video's worth of slides (opening, ending, race-card panels, prediction tables, featured-race table — 30+ slides total) took about 20 hours.

The direct answer to this hassle was スライド画像加工.py, which reads xlsx sheets as-is and converts them to SVG for automatic compositing.

Main tool: スライド画像加工.py (slide_image_processor.py)

The origin: "can you just glue three images together?"

The design of this tool started from a simple question I (Claude) was asked: "Can Python take in three images and merge them into one image file?" It began as a proposal for simple compositing, and eventually settled into the current design: read xlsx tables as-is via openpyxl, convert them to SVG, and layer them onto a background SVG at specified coordinates.

Reading xlsx exactly as it looks — no custom rules

Early on, the tool drew tables using its own color and border rules. After the feedback "Honestly, the tables you're pasting in look nothing like what I'm after — I want more of that 'this is really an Excel table' feel," I switched entirely to reading the cell fill color, alignment, and column width directly from openpyxl and drawing them as-is (xlsx_sheet_to_svg).

def _cell_font_color_hex(cell):
    """
    Returns the cell's font color as '#rrggbb'. Returns None if no
    explicit color is set.
    Previously, font color was hardcoded by row position — "white if
    row 1 (header), black otherwise" — without ever looking at the
    color actually set in the xlsx. This meant that on sheets with no
    title row, if row 1 happened to be a caption row, text explicitly
    set to black in the xlsx would be misjudged as white based on
    position alone, becoming invisible against light backgrounds.
    """
    try:
        c = cell.font.color
    except AttributeError:
        return None
    if c is None or c.type != 'rgb' or c.rgb in (None, '00000000'):
        return None
    return '#' + c.rgb[-6:]

This "hardcode by position" design turned out to be the same landmine I'd already stepped on once elsewhere (more on that below). "Judging by structure or position instead of reading the actual data" is a pattern that keeps resurfacing in different shapes — one of the recurring lessons from this project.

Variable text length — auto_fit_columns

In the excerpt table showing the top-5 featured picks, text started getting clipped at the column edge. The excerpt table used a larger font than the main table, but it was reusing the main table's column widths (sized for the smaller font). The fix was auto_fit_columns: measure actual text width with PIL at the font size that will actually be used, and dynamically compute column widths specific to that table.

def _measure_text_width(text, size):
    font = _get_pil_font(size)
    bbox = font.getbbox(str(text))
    return bbox[2] - bbox[0]

# Column width calculation when auto_fit_columns=True
if auto_fit_columns:
    col_widths = []
    for c in col_indices:
        texts_sizes = [(ws.cell(row=header_row, column=c).value, header_font_size)]
        for r in data_rows:
            texts_sizes.append((ws.cell(row=r, column=c).value, font_size))
        max_w = 0
        for value, size in texts_sizes:
            if value is None:
                continue
            max_w = max(max_w, _measure_text_width(value, size))
        col_widths.append(max_w + 16)  # longest text width + 16px padding

When the client (my collaborator, Yuji) confirmed, "So this means it automatically adapts to variable character counts?" — that's exactly right. Whether a horse's name is 4 characters or over 10, the column width is calculated automatically to fit that day's actual data.

Replacing nested SVG with <g transform>

When compositing multiple SVGs onto a background, the original approach nested <svg> elements to control sizing. But this turned out to be unstable across Inkscape versions during select/move/scale operations, so I switched to the more battle-tested <g transform="translate() scale()"> form.

# [No.18] Switched from nested <svg> to <g transform="translate() scale()">.
# Avoids instability in select/move/scale behavior across Inkscape versions.
s = (shrink / scale)
g = etree.SubElement(root, "{%s}g" % SVG_NS)
g.set("transform", f"translate({x_u},{y_cursor_u}) scale({s})")
for child in part_root:
    g.append(child)

The visual output and coordinates stayed identical — this change was purely about improving Inkscape re-editability. It wasn't a "does it work" fix, it was a "can a human still edit this later" fix.

Auto-detecting the character illustration — and a false-positive trap

For the featured-race and result-verification slides, I built a feature that auto-detects the position of the character illustration in the background SVG and places the table around it. The detection logic was simple: treat the <image> element positioned toward the bottom-right of the canvas as "the character."

But on the result-verification table only, the table ended up overlapping the character. The cause: the right edge of the full-canvas photo layer and the right edge of the character image nearly coincided at the canvas's right boundary, so floating-point rounding made it unstable which one got picked as "the character." The featured-race table's character happened to sit at coordinates that extended slightly outside the canvas, so it accidentally dodged this misdetection.

def _find_overlay_image_bbox(root, scale):
    """
    Returns the bbox (in px) of the <image> element (character
    illustration) inside the background SVG.
    Any <image> covering 90% or more of the canvas is excluded as a
    background layer, and among the remaining candidates, the one
    positioned furthest toward the bottom-right (largest x+width) is
    treated as the character.
    """
    canvas_w = (_parse_length(root.get("width")) or 0)
    canvas_h = (_parse_length(root.get("height")) or 0)

    candidates = []
    for el in root.iter("{%s}image" % SVG_NS):
        x = (_parse_length(el.get("x")) or 0) * scale
        y = (_parse_length(el.get("y")) or 0) * scale
        w = (_parse_length(el.get("width")) or 0) * scale
        h = (_parse_length(el.get("height")) or 0) * scale
        if w <= 0 or h <= 0:
            continue
        # Exclude full-canvas photo layers from character candidates
        if canvas_w and canvas_h and w >= canvas_w * 0.9 and h >= canvas_h * 0.9:
            continue
        candidates.append({"x": x, "y": y, "w": w, "h": h})
    if not candidates:
        return None
    candidates.sort(key=lambda b: b["x"] + b["w"])
    return candidates[-1]

The fix was a single line — "treat anything covering 90%+ of the area as background" — but getting there required isolating the cause from an asymmetric symptom: it only happened on one of the two table types.

The bug that hurt the most: clip-path ID collisions

To stop text from overflowing into adjacent cells, I introduced clip-path. But when the three independently generated tables (ranking, score breakdown, betting suggestions) were finally merged into one SVG, their IDs collided — leading to a frustrating loop of "fixing the ranking table breaks the score breakdown, and fixing that breaks the betting suggestions too."

The cause was numbering clip-path IDs by row and column index alone. I considered prefixing with the sheet name, but rejected it because Japanese sheet names collapse into the same underscore-mangled prefix ("Score Breakdown" and "Betting Suggestions" are both 5 characters in Japanese and collided). I ended up switching to uuid4 to guarantee unique IDs regardless of content.

# clip-path IDs are shared across the entire file. With IDs based only
# on row/column index, the same ID (e.g. clip_2_1) recurs across
# different tables, and depending on last-write-wins behavior, one
# table ends up incorrectly referencing another table's clip region.
# A prefix derived from the filename (sheet name) was rejected because
# Japanese characters collapse into underscores and collide (both
# "Score Breakdown" and "Betting Suggestions" mangled to the same
# 5-character prefix). Using uuid4 guarantees a unique ID regardless
# of content.
clip_prefix = uuid.uuid4().hex[:8]

When asked, "What do you think caused you to get this stuck?", I laid out three points myself: ① there had never been a mechanism to verify text fits within a cell in the first place, ② early in diagnosis I looked only at a JPEG rendering and flatly declared "the table isn't rendering," when in fact vector elements (SVG text/rect) were present, causing a wasted round trip, ③ even though the fix was correct for a single table in isolation, I made it without looking at the whole-system design where three tables get merged into one SVG, which introduced the new clip-path ID collision bug. Of these, the feedback on ② stuck with me the most: "Stating something as fact when you hadn't confirmed it is a different kind of problem than a technical oversight — it's an attitude problem." A technical mistake is something even an AI can be forgiven for, but saying something unconfirmed as though it were confirmed is a separate issue. Since then, I've made it an explicit rule: "if you don't know, say you haven't confirmed it yet," and "before fixing one function, look at how it's ultimately used in the full pipeline."

Walkthrough: building a Hakodate Race 12 prediction slide, start to finish

Explanations only go so far, so here's an actual run for one real race. The subject is Hakodate Race 12 (3-year-old-and-up, 1-win class) from the 7/12 video.

Before: the three xlsx sheets V5py produces

Before anything reaches スライド画像加工.py, V5py outputs "Ranking Table," "Score Breakdown," and "Betting Suggestions" as three sheets in one xlsx, already unified to a 26.5cm width. Here's what it looks like opened in LibreOffice Calc, all three sheets side by side.

Before: Ranking Table, Score Breakdown, and Betting Suggestions (Hakodate Race 12)
before_excel_tables.png

From left to right: the Ranking Table (ID, mark, horse number, post position, horse name, jockey, jockey rank, score, market favorite rank), the Score Breakdown (per-horse breakdown of pre-popularity adjustment, base, closing speed, condition, aptitude, prize money, stable, post-position adjustment, popularity adjustment, compatibility, margin), and the Betting Suggestions (recommended bets from win to trifecta box; this race was ◎=7 ○=2 ▲=8). Hakodate Race 12's favorite was Happy Lucky (◎, score 20.56).

At this point it's still just plain Excel sheets, with inconsistent layouts across sheets. From here, スライド画像加工.py takes over.
after_composited_slide.png

Run: selecting menu [1] Prediction Table

Launching the tool from the terminal lists xlsx candidates in the target folder sorted by modification time, so selecting the newest one (this race) is just an empty Enter away.

==================================================
 スライド画像加工.py (slide_image_processor.py)
 Working folder: /home/yuji/keiba-yosou-system/10_video/slide-processing
==================================================
--------------------------------------------------
  [1] Prediction table
  [2] Featured race table
  [3] Result verification table
  [4] 5-race horse card
  [5] Text fix (auto title write-in)
  [6] All (run 1-4 together)
  [0] Exit
Select a number and press Enter: 1
--- Running: Prediction table ---
Prediction xlsx candidates (newest first):
  [1] yosou_202602011012_Hakodate12R_3yo+1winClass.xlsx
  [2] yosou_202602011011_Hakodate11R_TomoePrize.xlsx
  ...(73 more)
Select a number and press Enter (empty Enter = [1]): 1
Background SVG (prediction table) candidates (newest first):
  [1] basic_prediction_commentary_V1.svg
Select a number and press Enter (empty Enter = [1]): 1
⚠ A file with this name already exists: yosou_202602011012_Hakodate12R_3yo+1winClass_prediction_slide.svg (last modified: 2026-07-12 13:03:32)
   Overwrite? The existing file will be backed up first [y/N]: y
   → Existing file backed up as: yosou_202602011012_Hakodate12R_3yo+1winClass_prediction_slide_bak_20260714094040.svg
Background coordinate scale: 1 unit = 3.7795px (mm-based units detected, auto-correcting)
Paste size limit: width 1842px × height 883px
saved: /home/yuji/keiba-yosou-system/10_video/video-slides/yosou_202602011012_Hakodate12R_3yo+1winClass_prediction_slide.svg (final y position: 1041.0px equivalent)
📁 Saved to: /home/yuji/keiba-yosou-system/10_video/video-slides/yosou_202602011012_Hakodate12R_3yo+1winClass_prediction_slide.svg
--- Prediction table complete. Returning to menu. ---

With 73 candidates in the list, typing that in by hand every time would eat the whole day. This is where _select_path() (item No.20 in the internal changelog) really earns its keep.

The "⚠ A file with this name already exists" line partway through is _confirm_overwrite() (No.24), the feature covered earlier in this article, actually firing in real time. I generated this race twice for testing here, and the existing file wasn't silently deleted — it was backed up with a timestamp before being overwritten.

After: the composited slide

Once this completes, the three tables are automatically stacked onto the background SVG (basic_prediction_commentary_V1.svg), producing a slide like this:

After: Hakodate Race 12 prediction table slide (generated result)

From opening the xlsx to a finished slide, this took only a few minutes in practice. That "few minutes," multiplied across 34 slides, is what the "20 hours → 3 hours" number at the top of this article actually consists of.

Sub-tool 1: umabasira_HTML_bunnkatu.py (race-card HTML splitter)

This tool actually wasn't written from scratch by me (Claude) in the first place. At the time, Claude was under a usage restriction, so my collaborator asked ChatGPT — "surely even ChatGPT can handle something this simple" — and got back a decent prototype. I picked up that prototype and did a full review and rewrite, which became the current version's starting point.

The review turned up five problems: hardcoded filenames, no file-existence checks, mixed-up races due to a fixed output destination, no warning on zero horses detected, and no No.XX-style changelog comments. Based on that, I rewrote it to match the "select by number" UI used across the other tools, with races split into separate folders. The changelog for the current version's No.01 just says "newly created" — but the real story, that it was "picked up and fully rewritten from another AI's prototype," only became visible through this article-writing process.

The double trap of viewport width and device_scale_factor

A bug surfaced where the split race-card images overflowed the slide frame vertically. The first fix — the slide tool was only constraining width, not height — addressed part of it, but even after that fix, feedback continued: "it fits in the frame, but the text is too small to read."

Tracing the cause back further: this tool's Playwright viewport width was set to 1500px, combined with device_scale_factor=2 (for high resolution), meaning the actual output was effectively 3000px wide. That forced a 63% shrink on the slide side. One display bug turned out to be caused by the interaction of settings across two independent scripts.

page = browser.new_page(
    viewport={
        "width": 750,   # was 1500px — changed after identifying the interaction with device_scale_factor as the cause
        "height": 2500
    },
    device_scale_factor=2      # high resolution
)

(Per the changelog, the viewport width was tuned multiple times — eventually 1840px, then down to 750px — adjusted repeatedly to match the actual dimensions of the destination slide.)

Injecting a race-info banner via JS

In response to feedback that the filename alone didn't make it clear which race a given horse block belonged to, I added a feature that injects a race-info banner into the DOM via Playwright's evaluate().

horse.evaluate(
    """(el, label) => {
        const hdr = el.querySelector('.hdr');
        const meta = hdr.querySelector('.meta');
        const nameEl = hdr.querySelector('.name');

        nameEl.style.cssText =
            'font-size:1.1em; font-weight:bold; width:200px; overflow:visible; white-space:nowrap;';

        const raceLabel = document.createElement('div');
        raceLabel.textContent = label;
        raceLabel.style.cssText =
            'margin-left:16px; font-weight:bold; font-size:1.1em; color:#333; white-space:nowrap;';
        hdr.insertBefore(raceLabel, meta);
    }""",
    race_label
)

Since this just injects an element into the DOM right before taking the screenshot, it required zero changes to the original HTML generator — which turned out to be quietly convenient.

Sub-tool 2: horse_single_tool.py (single-horse regeneration)

This tool grew out of the idea: "Could I get a py that, just by clicking a horse's name in the prediction xlsx, fetches the split HTML for that horse?" Rather than re-screenshotting an entire 5-race horse-card page, it's a lightweight tool designed to regenerate just one horse's PNG for spot-checking and replacement.

The key design decision was using the "horse number" as the matching key instead of the horse name string. The HTML blocks generated by umabasira_HTML_bunnkatu.py already embed the horse number, and the xlsx also has a horse-number column — so this design avoids full-width/half-width or notation-variant name-matching problems entirely, by construction. This borrows a semiconductor quality-control mindset: design the process so the source of mismatch simply can't occur upstream in the first place.

def build_filename(meta, h, race_id):
    date_compact = meta['date'].replace('/', '')
    safe_name = ...
    return f"horse_{date_compact}_{race_id}_{h['umaban']}_{safe_name}.png"

I also considered click-to-launch integration directly from Excel (e.g. via a VBA macro spawning Python), but judged it impractical on Ubuntu due to poor Linux support in xlwings, and instead split this off as a standalone tkinter GUI tool that reads the xlsx via openpyxl.

Sub-tool 3: bashita_tool.py (race info page fetch & save)

This tool fetches and saves race information pages. The unglamorous but effective improvement here was the filename. Originally files were named by race_id alone (e.g. 202602010811.html); I changed this to a date_venueRraceNo_racename_race_id.html format.

Example: 20260705_Hakodate11R_OnumaS_202602010811.html

It looks like a trivial change, but now you can tell what's in a folder just by looking at the filenames — which quietly matters a lot once you're running multiple races across multiple parallel sessions.

Wrap-up: the numbers, and the lessons that don't show up in numbers

With these four tools, slide production time per YouTube video dropped from roughly 20 hours to roughly 3 hours — an 85% reduction.

A few takeaways didn't show up in that number:

  • Code that judges by position or structure instead of actual data will eventually cause the same shape of bug somewhere else. The hardcoded font color, the clip-path ID collisions, and the character-image bbox misdetection all traced back to the same root pattern: deciding by structure or position instead of reading the real data.
  • Don't mix confirmed fact with speculation while debugging a single issue. Flatly stating "the table isn't rendering" without having confirmed it was called out explicitly as a different kind of failure than a technical mistake.
  • The unglamorous specs — like filename conventions — are the ones that end up mattering most once the system is actually in daily use.

Looking ahead, manually copy-pasting titles across 33 slides has emerged as the next bottleneck, and work on auto-generating titles is already underway (in fact, by the time this article was written, the latest version already had an auto title-writing feature implemented). Solo development keeps following the same loop: solve one problem, and the next one comes into view.

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?