Revayat Comic — comics into Persian
Run the eleven steps below in order. Each is a command plus a rule for what to do with its output. Do not improvise a different order, and do not skip a step because the previous one looked fine.
Set three variables once, then use them everywhere:
SKILL_DIR— the folder holding this file. In a Claude Code plugin it is${CLAUDE_PLUGIN_ROOT}/skills/revayat-comic.WORK— a working folder for this chapter, e.g.work/.PY— the Python interpreter. Resolve it once;python3does not exist on most Windows installations, so a command line that hard-codes it works on two platforms out of three:- macOS / Linux:
PY=python3 - Windows:
PY=python— orPY="py -3"if the launcher is what is installed - if neither runs,
doctorin step 1 will not start, and that is the signal
- macOS / Linux:
Every command is $PY $SKILL_DIR/scripts/revayat-comic.py <stage> ….
The five rules that must never be broken
- Never reverse Persian text, and never paste pre-shaped Persian into the worksheet. Write ordinary Persian in logical order. The renderer puts it on the page in the right direction; reversing it produces something that looks right in one viewer and is broken everywhere else.
- Never invent, drop, renumber or reorder a
@@region id. Return exactly the ids you were given. To say a region has no text in it, usedrop: yes— that is what it is for. - Never hand-edit
comic.jsonto fix a translation. Fix the worksheet and re-run merge. The one exception is the glossary table, which is edited there on purpose. - Never replace a whole page with a regenerated one. Only the pixels
inside an authorised mask may change;
qa checkproves it, and a page that fails that check does not ship. - Never shorten a balloon to make it fit. If Persian overflows, the answer is a shorter translation that still says everything, not a summary and not six-point type.
Step 1 — Check the tools
$PY $SKILL_DIR/scripts/revayat-comic.py doctor
"ready": true→ continue."ready": false→ runpip install -r $SKILL_DIR/requirements.txt, then rundoctoragain.
Two fields in persian decide whether the output is correct rather than merely
present. Read them now, not after building a chapter:
| Field | Meaning | Action |
|---|---|---|
"raqm": true |
Pillow shapes and reorders Persian itself, through HarfBuzz and FriBidi | nothing; this is the good path |
"raqm": false |
Persian will be pre-shaped by hand into presentation forms | works, and it is fixable: libraqm is in every Pillow wheel and needs a FriBiDi library at run time. Put fribidi.dll / fribidi-0.dll / libfribidi-0.dll on PATH (Windows) or install libfribidi (Linux/macOS) |
"font" is a path |
a Persian-capable font was found | nothing |
"font": null |
there is no font on this machine that can draw Persian | install Vazirmatn, then pass --font at step 9 |
"font_draws_persian": false |
the font was found but renders tofu | pick another; --font at step 9 |
Step 2 — Import the chapter
$PY $SKILL_DIR/scripts/revayat-comic.py import "<chapter.cbz>" --out $WORK \
--source-language ja --direction rtl
--direction is how the source is read, not the target. Japanese manga is
rtl; Korean webtoons, Chinese manhua and Western comics are ltr. Getting it
wrong does not fail — it silently numbers the balloons backwards, and the
translation then answers questions that have not been asked yet.
Accepted input: CBZ, CBR, a comic PDF, a folder of images, or one image.
| What you see | What to do |
|---|---|
"pages": N matching the chapter |
continue |
webtoon_strips is not empty |
this is a webtoon; use --direction ltr and expect one long column per file |
an error naming rarfile or unrar |
CBR needs an unrar binary; convert to CBZ instead if that is easier |
The original pages are copied to $WORK/pages/ and hashed. Nothing in the
pipeline ever writes to them. Every later stage writes a new file.
Step 3 — Find the text
Three commands, always run together:
$PY $SKILL_DIR/scripts/revayat-comic.py detect --doc $WORK/comic.json
$PY $SKILL_DIR/scripts/revayat-comic.py mask --doc $WORK/comic.json
$PY $SKILL_DIR/scripts/revayat-comic.py crops --doc $WORK/comic.json
| What you see | What to do |
|---|---|
totals.speech in the right region of plausible |
continue |
pages_without_text covering many pages |
the thresholds do not fit this book — see references/detection.md |
a warning about excessive coverage |
the mask matched artwork, not lettering; fix it before cleaning |
detect finds balloons well and free-floating sound effects badly. That is
expected and it is why step 5 exists: you will see the page and can correct it.
Step 4 — Worksheets
$PY $SKILL_DIR/scripts/revayat-comic.py worksheet build --doc $WORK/comic.json
One worksheet per page in $WORK/worksheets/pNNNN.txt.
If this refuses with "refused": "stale-worksheets", regions moved after some
pages were already translated, so their ids no longer point at the same
balloons. Re-translate those pages, or pass --force once you have decided the
regions did not really move.
Step 5 — Read the page and translate it
This is the step that makes the result good, and it is yours, not a
script's. For each $WORK/worksheets/pNNNN.txt, write
$WORK/worksheets/pNNNN.done.txt.
One page at a time, merged before the next one starts. This is the default and it is the only order that keeps the guarantee below true.
A page's context is the pages before it — the dialogue they settled, the register
each character was given, the names that were locked. On a fresh chapter none of
that exists in comic.json until a merge puts it there, so a page translated
before its predecessor is merged gets an empty context and produces exactly the
drift the context was added to prevent.
page 1 → merge → glossary scan / lock → build page 2 context
page 2 → merge → glossary scan / lock → build page 3 context
...
The invariant this protects: when page N is translated, pages 1…N−1 are in
comic.json, so page N's context carries their exact source and Persian. That is
what test_page_two_sees_page_one_only_after_it_is_merged pins, and it is only
true page by page.
worksheet merge (Step 6) and glossary scan (Step 7) run after each page,
not once at the end. Both are safe to re-run, both are cheap, and merge
reporting missing_outputs for the pages whose turn has not come is expected.
Pages translated together cannot see each other — they all read the same pre-merge snapshot. So a batch of four is four pages of lost continuity between themselves, and the more a chapter introduces (a new character, a place, a term of address) the more that shows.
It is a real option when you know the pages are independent — an action sequence with no dialogue, a chapter you have already read through, a re-run where the glossary is fully locked from the previous pass. It is a speed-for-consistency trade, not the correct path, and if you take it, run merge and glossary scan after each batch just the same.
Lock what you already know before page 1. If this is chapter 12 of a series
you have already translated, copy the locked glossary.entries from the previous
chapter's comic.json into this one and run glossary scan before translating
anything. Names decided in chapter 3 should constrain page 1 of chapter 12, not
be rediscovered.
First, build the page's chapter context. A sub-agent that sees only its own
page translates it correctly and inconsistently: a character who was شما on
page 4 becomes تو on page 5, and a settled term drifts. This is bounded,
deterministic and cheap:
$PY $SKILL_DIR/scripts/revayat-comic.py context --doc $WORK/comic.json --page pNNNN
It returns constraints (the locked glossary and the rules — not open to
interpretation) and context (the previous pages' dialogue nearest-first, who
has spoken, what the next page holds, and any scene or style notes a person
wrote). Paste that JSON into the sub-agent's prompt. Fields nobody has filled in
come back empty, and empty is correct — never invent a scene description or a
character's register to fill the gap.
It reads comic.json, so it only knows what has been merged. Build it
immediately before translating that page, after the previous page's merge —
building it earlier gets you a snapshot missing exactly the pages it was supposed
to carry.
This is enforced, not just advised. If an earlier page has a .done.txt
that has not been merged, context refuses and tells you to merge first.
--allow-unmerged overrides it for the deliberate case and records which pages
are missing from the package.
Give the sub-agent exactly this:
Read
$WORK/worksheets/pNNNN.txtand write$WORK/worksheets/pNNNN.done.txt.Look at both images the worksheet names before you write anything. Use the Read tool on each:
crops/pNNNN/overview.png— the whole page with every region numbered in reading order. This is your context: who is speaking, what they are looking at, which balloon answers which.crops/pNNNN/sheet*.png— the crops themselves, enlarged and labelled with the region ids.Read
$SKILL_DIR/references/translation-policy.mdfirst and follow it.The chapter context below is bounded and already filtered. Everything under
constraintsis settled — use the locked glossary terms exactly as written. Everything undercontextis there to keep you consistent with the pages before this one: match the register the same characters were given, and keep a sentence that continues from the previous page reading as one sentence.<paste the context package here>Output format — this is mechanical, get it exactly right:
- Copy each
@@ <id> …line unchanged, in the same order.- Under it,
src:is what the balloon says in the original, andfa:is the Persian.- A field continues on the following lines until the next field or the next
@@. Output nothing else: no preamble, no commentary, no summary.Rules:
The names table at the top of the worksheet is binding. Use exactly the Persian it gives.
Translate what the character is saying, in the register they are saying it in. A shout is short and blunt; a thought is quieter. Match the art.
Persian runs longer than Japanese. Keep balloons tight — this is dialogue in a small space, not prose.
Never summarise, never skip a balloon, never merge two.
If a crop shows the detector was wrong, correct it:
drop: yeswhen there is no text there at allkeep: yeswhen there is text and it should stay in the artwork — a background shop sign, a logo, an effect you do not want replaced. Do not reach fordrophere: it means "no text", and using it makes the terminal census count real lettering as a false detection.kind:,speaker:andnote:still apply beside it, and akeepcounts as a review: it locks the region, so a laterdetectrun leaves the decision alone.kind: sfx/sign/narration/thoughtwhen it is misclassifiedspeaker: <short stable name>— the same name every time, on every pageCheck every crop for two balloons in one box. This is the commonest way the page loses text, it happens several times a volume, and the detector cannot see it — four different measurements were tried and none separates a touching pair from one balloon with a tail. You can see it instantly: the crop shows two outlines and two blocks of text. When it does,
drop: yesthe region and add each balloon back with its ownbox:.If the overview shows text with no region on it, add one. Free lettering is the weak case, and a whole panel is occasionally taken for a balloon and swallows what is drawn inside it — so a full worksheet is not the same as a full page.
@@ +bump sfx horizontal box: 742 436 58 24 src: BUMP fa: تلپ
box:isx y w hin the page's own pixels, which is whatoverview.pngis drawn at, so read the numbers straight off it. Addpolarity: darkfor white lettering on black. Merging allocates the real region id.
Check what is left at any time:
$PY $SKILL_DIR/scripts/revayat-comic.py worksheet status --doc $WORK/comic.json
Step 6 — Merge
$PY $SKILL_DIR/scripts/revayat-comic.py worksheet merge --doc $WORK/comic.json
| Field | Meaning | Action |
|---|---|---|
"ok": true |
everything landed | continue |
missing_outputs |
those pages were never translated | translate them — expected mid-chapter for pages whose turn has not come |
missing_regions |
ids were dropped from a worksheet | re-do those pages |
unknown_regions |
ids were invented | re-do those pages |
duplicate_regions |
an id appears twice | re-do that page |
empty_translation |
a region has src: but no fa: |
fill it in, or drop: yes |
stale_worksheets |
regions changed after translation | re-do those pages |
Re-running merge after a fix is always safe, and it is meant to be run after
each page rather than once at the end — that is what puts a page's Persian into
comic.json where the next page's context can see it.
Step 7 — Names and typography
$PY $SKILL_DIR/scripts/revayat-comic.py glossary scan --doc $WORK/comic.json
Open $WORK/comic.json and, for each entry under glossary.entries that has an
empty target, fill in the Persian and set "locked": true. This is the one
hand-edit that is expected. Names decided once here stay consistent for the
rest of the chapter, and every later worksheet prints the table.
Run this after each page, not only at the end. A name locked on page 1
constrains every page after it, through both the worksheet table and the
context package; a name locked only at the end constrains nothing that has
already been translated. Carry the locked entries into the next chapter's
document too.
Then the mechanical Persian pass — safe to run twice:
$PY $SKILL_DIR/scripts/revayat-comic.py falint fix --doc $WORK/comic.json
Add --digits keep if the comic must keep Latin numerals.
Step 8 — Clean the artwork
$PY $SKILL_DIR/scripts/revayat-comic.py clean --doc $WORK/comic.json
| Field | Meaning |
|---|---|
totals.flat |
balloons repainted in their own colour — the good case |
totals.inpaint |
lettering that sat on artwork and had to be reconstructed |
totals.keep |
sound effects deliberately left in the drawing |
inpaint_heavy_pages |
look at these; Telea smooths, it does not redraw |
To use a better cleaner than this ships with, clean the pages elsewhere and
pass the folder: --external cleaned/, named pNNNN.png. Only the masked
pixels of those images are used, so even a generative model that redrew half
the page cannot change artwork it was not asked to touch.
For lettering drawn onto the artwork, give the model the whole patch rather than the letter shapes — clipped back to strokes a few pixels wide, even a good reconstruction reads as repaired rather than redrawn:
$PY $SKILL_DIR/scripts/revayat-comic.py mask --doc $WORK/comic.json --free-lettering solid
$PY $SKILL_DIR/scripts/revayat-comic.py clean --doc $WORK/comic.json --external reconstructed/
Balloons ignore the flag. A solid mask has exactly two valid consumers, and
clean refuses it without one of them:
# an image-edit provider, called for you, only where it is actually needed
$PY $SKILL_DIR/scripts/revayat-comic.py clean --doc $WORK/comic.json --provider <name>
clean refuses solid masks with neither --provider nor --external,
because painting one flat or inpainting it blanks a rectangle out of the drawing.
See references/artwork-preservation.md.
Step 9 — Set the Persian
$PY $SKILL_DIR/scripts/revayat-comic.py typeset --doc $WORK/comic.json \
--font "Vazirmatn-Medium.ttf"
--font is optional; without it the best Persian face on the machine is used.
| Field | Meaning | Action |
|---|---|---|
"shaping": "raqm" |
correct shaping and direction | nothing |
"shaping": "reshaper" |
the fallback ran | works; mention it in the report |
overflow is empty |
everything fitted | continue |
overflow lists regions |
those translations are too long | shorten them in the worksheet, re-run merge, falint and typeset |
--min-size is a floor, not a suggestion. It will not go below it silently.
Step 10 — Gate
$PY $SKILL_DIR/scripts/revayat-comic.py qa check --doc $WORK/comic.json
Do not export while "ok" is false. Fix by code:
| Code | Meaning | Action |
|---|---|---|
artwork-modified |
pixels changed outside the authorised mask | do not ship; re-run clean and typeset for that page |
source-text-survived |
the mask missed part of the lettering, so the original script is still on the cleaned page | re-run mask with a larger --grow for that page, then clean |
source-modified |
an original page file was edited after import | restore it, or re-import |
page-missing / page-size-changed |
an output is gone or resized | re-run the stage that makes it |
untranslated-region |
a region has no Persian | translate it, or drop: yes |
source-script-left |
Japanese, Korean or Chinese survives inside the Persian | re-do that region |
not-persian |
the target text is not Persian at all | re-do that region |
text-overflow |
it does not fit at the minimum size | shorten the translation |
reading-order-broken |
the numbering is not 1..n |
re-run detect for that page |
mask-excessive (warning) |
more than 28% of a page would be repainted | tighten detection |
duplicate-translation (warning) |
different sources, identical Persian | a reply was pasted twice; re-do both |
low-confidence-region (warning) |
the detector guessed and nobody checked | look at the crop |
glossary-drift (warning) |
a locked name was rendered differently | re-do that region |
typography (warning) |
Arabic letter forms, Latin quotes, stray punctuation | run falint fix |
sfx-untranslated (warning) |
a sound effect was left drawn | expected under the keep policy |
visual-note (advice) |
a model's opinion from the optional qa visual pass |
read it and decide; it never passes or fails a run |
Add --strict to make warnings blocking, for publication work.
Read stats.states as well as ok. Every detected region lands in exactly
one terminal state, and there is deliberately no state meaning "it quietly
disappeared":
| State | Meaning |
|---|---|
translated |
carries Persian |
kept_by_policy |
left as drawn on purpose — a sound effect under keep |
dropped_false_detection |
you said there is no text there |
needs_review |
seen but unresolved: overflowing, or a doubt you noted |
unresolved |
must be zero — detected and then forgotten |
unresolved is not its own error — every region in it is already blocked by
untranslated-region, and two codes on the same rows is noise. The census is
there to be read: the other four are all decisions, so report the counts to
the user rather than only the total.
Step 11 — Export and report
$PY $SKILL_DIR/scripts/revayat-comic.py export --doc $WORK/comic.json \
--out out/chapter-fa.cbz
$PY $SKILL_DIR/scripts/revayat-comic.py qa package --doc $WORK/comic.json \
--file out/chapter-fa.cbz
--out decides the format: .cbz, .pdf, or a folder.
Do not tell the user the file is ready while qa package reports
"ok": false.
| Code | Meaning | Action |
|---|---|---|
archive-invalid |
the package will not open, or its page names do not sort into reading order | re-run export |
archive-page-count |
the package has a different number of pages from the document | re-run export; if it repeats, a page failed to write |
Then report: where the file is, how many pages and balloons, which sound-effect policy was used, whether shaping used RAQM or the fallback, anything QA flagged that you chose not to act on, and this limitation —
Sound effects drawn into the artwork are kept as drawn unless you asked for them to be replaced. Redrawing hand-lettered art is a lettering job, not a translation job, and doing it badly damages the page.
Sound effects
Set the policy before step 8, in comic.json under meta.sfx_policy:
| Policy | Behaviour | When |
|---|---|---|
keep |
the original stays, untouched | the default, and right for most chapters |
translate |
the original is removed and Persian is set in its place | clean, simple lettering |
bilingual |
the original stays and a small Persian gloss is added | reference and study editions |
annotate |
the artwork is untouched and the meaning is noted beside it | busy pages |
If an SFX reading is uncertain, prefer keep. Erasing hand-drawn lettering to
replace it with a guess is worse than leaving it.
References
Read these only when the step points at them:
references/translation-policy.md— what to give the translating sub-agentreferences/persian-typesetting.md— RTL, shaping, fonts, fitting a balloonreferences/detection.md— thresholds, difficult pages, correcting a regionreferences/artwork-preservation.md— masks, cleaning tiers, what QA provesreferences/sound-effects.md— the four policies and how to choosereferences/ocr.md— reading with your own eyes, and when a model helpsreferences/serving.md— MCP and HTTP, for a host that cannot run this CLIreferences/troubleshooting.md— the failures you are most likely to hit