Cancel anytime. Your data stays yours — see upgrade options once you're in.
Questions, answered.
Is Cardz Studio free?
Yes. Cardz Studio has a free plan for making flashcards and mind maps, with an optional paid upgrade for unlimited cards and up to four student profiles.
Does it do mind maps as well as flashcards?
Yes. You can make flashcards for recall practice and mind maps for visual, connected revision in the same app.
Can it make flashcards from my notes or a photo?
Yes. Cardz Studio can turn typed notes, a photo or a PDF into flashcards using AI, which you can then edit.
Is it good for exam revision?
Yes. It's built for school and exam revision, combining flashcards, spaced repetition and mind maps so students remember more and see how topics connect.
Can parents track their student's revision?
Yes. A parent account can add up to four student profiles and see study time, topics and weekly goals from a parent dashboard.
Does it work offline?
Once the app has loaded, your cards are saved on your device, so you can keep studying them without a connection.
Your folders and flashcards. Tap a folder to open it.
Select an element to edit it.
Front · Question
Difficulty level
How hard is this question? Level 1 is a quick recall; level 5 asks for real judgement. Shown to the learner as a badge.
Answer checking
Self-assessed: the learner judges their own answer, as usual. AI-graded: they type an answer before flipping the card, and it's checked against your model answer — good for definitions and short-answer questions.
Fill-in-the-blank
Type a sentence, highlight a word, then tap Make blank. In study the blanks are hidden until the card is flipped.
Preview
Tags
Add a word or short phrase, then press Enter. Search #tagname to find every card with that tag.
Deck
Card 1 of 6
How sure are you? (optional)
How well did you know it?
Be honest — it just decides when you see this card again.
Pick the right answer
Mock exam · no feedback till the end⏱ 0:00
Tap each term to its match
Progress
A gentle overview — no pressure, just where things stand.
Review forecast
How many cards will be due each day this week, so you can plan around it.
Your forgetting curve
How much you still remember after a gap of a few days or weeks. Each time you review something, its line gets flatter — you forget it more slowly.
How well you judge what you know
From the quick “How sure are you?” checks. It is only a mirror — it never changes when your cards come back.
Your focus areas
Subjects where a few minutes of practice will make the biggest difference.
Cards that keep slipping
Give these a bit of extra love. Tricky cards are completely normal — when they're due, they'll come up first in your review.
Cards by difficulty level
A healthy deck spans levels 1–3 at least. All level 1 is a vocab list; all level 5 will just bruise confidence.
Mind Maps
Turn a deck into a visual map, or start with a blank canvas.
Settings
Make it yours. Everything here is saved on this device.
Appearance
Pick a premade look, or choose Custom and set your own.
Card editor
Set the background behind the card-editing toolbar. The tool buttons stay the same soft, colourful style on every option.
Toolbar backgroundThe bar of tools at the top of the card editor.
Language
Translates the app’s menus, buttons and screens. Your own cards stay in the language you wrote them, and AI marking replies in your chosen language.
LanguageDefault: English 🇬🇧
Underlines misspelt words in your English cards. Tap (or right-click) an underlined word to pick the right spelling.
SpellingDefault: British English
Text & reading
Font
Text size100%
Default image size on new cards50% of the card width
Capital letters as you typeStart every sentence and every new line with a capital letter in your cards.
The River Nile flows into which sea?
The Mediterranean Sea — it's the longest river in Africa, flowing north through Egypt.
Motion & accessibility
AnimationsTurn off the card flip and transitions.
Shuffle cards for studyMix up the order each time you study (still weighted toward cards you're due to re-learn), so answers aren't memorised by position.
Open in full screenGo full screen on your first tap each time the app opens. If you leave full screen, we stop doing this on this device.
Don't rely on colour aloneAdds a symbol as well as a colour wherever a mind map shows how well you know something — useful if reds and greens look similar to you.
Family & Parents
A 4-digit code keeps this area for grown-ups only. Set one, then use it to check in on study time, topics, and goals.
Parent PINNot set yet
Parent DashboardTime on the app, time per topic, and weekly goals.
Accessibility per student
Off, Dyslexia-friendly, or Focus / calm — set it for each student. Mirrors the profile menu and the Parent Dashboard.
Backup & safety
Signed in, your decks sync to the cloud automatically. A backup file is still a handy personal copy — export it before a big change, or if you're not signed in yet.
Support
Contact supportTell us what is wrong — we read every message.
Terms & Conditions
Last updated 10 October 2026.
These policies are written in English only. Everything else in the app is in your language.
1. The service. Cardz Studio is a flashcard-based revision app for students, used by a parent or guardian ("you") together with up to four student profiles under a single family account. You are responsible for the account and for supervising how your students use it. Cardz Studio is for people aged 13 and over: student profiles are for ages 13 to 18, and where the law requires a parent’s permission for someone under 16, the parent or guardian account holder gives it.
2. Plans & billing. A Free plan is available (1 student profile, up to 20 cards). Paid plans are billed monthly or annually and include 1 student profile; up to 3 additional student profiles can be added at any time for a small extra fee each, charged on the same billing cycle. Payments are processed securely by Stripe — we never see or store your card details. Prices are shown in your local currency where available.
3. Changing or cancelling your plan. You can add or remove a student profile, or switch between monthly and annual billing, at any time from Settings — changes take effect immediately and any difference is charged or credited automatically (pro-rated) by Stripe. Cancelling stops future billing but keeps your access live until the end of the period you've already paid for; nothing is deleted when you downgrade or cancel. Refunds for time already used are not offered except where required by law.
4. Your content. Any decks, cards, images or notes you or your students create remain yours. You grant us a licence to store and process that content solely to provide the service to you (for example, cloud sync and backup).
5. Fair use. Please don't use Cardz Studio to upload unlawful, infringing or harmful content, or to attempt to disrupt or reverse-engineer the service.
6. Availability. We aim to keep the service available and your data backed up, but we can't guarantee uninterrupted access, and the service is provided "as is" without other warranties. To the extent permitted by law, our liability to you is limited to the amount you've paid us in the 12 months before a claim.
7. Changes. We may update these terms occasionally to reflect changes to the service or the law; continued use after an update means you accept the revised terms. Material changes to pricing will always be flagged in advance.
8. Governing law. Cardz Studio is run by A Roll Media (FZE), a free-zone company registered in Sharjah, United Arab Emirates. These terms are governed by the laws of the Emirate of Sharjah and the federal laws of the United Arab Emirates that apply there. If you use Cardz Studio as a consumer, you keep any mandatory consumer-protection rights you have under the law of the country where you live, and nothing in these terms limits them.
9. Contact. Questions about these terms — reach us via the support link in your account settings.
Privacy Policy
Last updated 29 September 2026.
These policies are written in English only. Everything else in the app is in your language.
What we collect. Your email address (for sign-in and account recovery); the names/avatars you choose for the parent and student profiles on your account; the decks, cards, images and study activity created inside the app; and basic billing status (plan, renewal date) from our payment provider — never full card numbers, which Stripe holds directly.
Why. To run the service you've signed up for (storing and syncing your decks, sending sign-in codes, processing payments), and nothing beyond that — we don't sell your data or use it for advertising.
Students. Student profiles are created and managed entirely by the parent/guardian account holder; students don't have their own separate sign-in, email address, or account. Cardz Studio is intended to be set up and supervised by an adult, and is for ages 13 and over: we don’t knowingly collect information about anyone younger, and if we learn we have, we will delete it.
Who else sees it. Three processors, all under data-processing agreements: Supabase (secure hosting, sign-in and database storage), Stripe (payment processing), and Anthropic (the AI that drafts flashcards and mind maps, marks typed answers and offers hints and explanations). None is permitted to use your data for their own purposes.
What the AI receives, and what it doesn’t. Only when your student chooses to use an AI feature:
Making cards or a mind map — the notes text (or a mind-map branch’s text) and — if they attach one — a shrunk copy of that photo. Making cards also sends the questions already in that deck (up to 40) so it doesn’t repeat them.
Checking an answer, or asking for a hint, an explanation or an easier or harder rewrite — that card’s question and model answer, plus what your student typed. Marking can also include the card’s marking notes (accepted answers, keywords, rubric, marks, command words) and difficulty level, their last couple of typed answers to that same card with how each was marked, and how many times they’ve slipped on it. A mind-map memory check sends the branch names in the same way.
With every request — a little context so the wording suits them: their age (as a number and an age band), the celebration style set for their profile, the app’s language and, only if you switched the exam countdown on, how many days are left. Where a deck or map is involved, its name and its main folder’s name (or the map’s title) go too. Those names are whatever you typed, so it’s best not to put a student’s full name in them.
It never receives their name, their email, their wider study history or anything about your other students. PDFs and photos are read on the device first; the PDF file itself is never uploaded. Nothing sent to the AI is stored by us beyond a daily count of how many requests an account made, kept for 14 days. A photo is only saved at all if a card you keep actually uses it — and then it is simply part of your student’s own deck.
Where it's stored. Your account data is hosted with Supabase; Stripe may process payment data in other countries with appropriate legal safeguards in place for international transfers.
How long we keep it. For as long as your account is active, plus a reasonable period afterwards in case you return or as required for billing/legal records. You can export a full backup of your decks at any time from Settings, and can ask us to delete your account and data.
Your rights. You can access, export or ask us to delete your data at any time — export is built into Settings; for anything else, contact us via the support link in your account settings.
Cookies. See our separate Cookie Policy below.
Changes. We'll update this policy if what we collect or why ever changes, and note the date at the top.
Cookie Policy
Last updated 10 October 2026.
These policies are written in English only. Everything else in the app is in your language.
Cardz Studio keeps things simple: we don't use advertising or tracking cookies, and there's no third-party analytics watching you use the app.
What we do use:
Essential local storage — to keep you signed in, remember your theme and settings, and save your decks locally as a safety net. This is necessary for the app to work and can't be turned off without also turning off sign-in.
Payment-page cookies — when you subscribe, Stripe's own secure checkout page may set its own cookies to process your payment safely. These are set and controlled by Stripe, not by us — see Stripe's own privacy and cookie policy for details.
Because we don't use optional tracking cookies, there's no cookie-consent banner to click through — the only "cookies" here are the essential ones needed to make the app function, plus whatever Stripe uses on its own checkout page during a purchase.
Account
Signed in — your decks sync to the cloud automatically.
Delete your account and everything in it — this cannot be undone.
Parent Dashboard
A quiet look at study habits — not a scoreboard, just the facts.
Add to home screen
One tap gives your student their own app icon — full screen, no browser bar, faster to open.
Tap a student to see their dashboard
Practice routine
A calm, read-only look at how practice fits into the week. Nothing here is a score, and a day off is just a day off.
Study settings
Choose a student, then set their daily goal, reminder, session length and how their cards are scheduled. Each student keeps their own.
Recall & improvement
How much your student remembers over time — a rising line means it's sticking.
Sibling leaderboard
A friendly nudge, not a ranking — see how everyone in the family is getting on, side by side.
Show sibling comparisonTurn off to hide this from the Parent Dashboard — your own stats stay visible either way.
Loading…
Each student's PIN
Optional. A PIN keeps each student's decks and progress to themselves on this shared device — and stops siblings fighting over each other's cards. Forgotten one? Reset it here.
Study style
We suggest a level based on each student's age — change it any time.
Accessibility & focus
Switch a student into a dyslexia-friendly or calm focus mode. Applies only to that student.
AI help
Choose what AI may do for each student. When it is off, every AI button is switched off for that student and says why.
AI features send the text of a card, note or map topic to an AI service to get a reply. Names and email addresses are taken out first. You can switch AI off for any student here.
Danger zone
Remove a student's profile for good. To delete the whole account instead, use Danger zone under your own Parent profile.
Focus areas
Where to focus next, worked out from confidence ratings and how long it's been since a topic was studied.
Stuck cards
Cards that keep failing — often the card needs rewording, not more repetition.
Study activity, last 12 weeks
Each square is a day. Darker means more time studying.
LessMore
Exam readiness by subject
How much of each subject is likely remembered right now, blended with how much of it has been covered so far.
Time per topic
Topics are the top-level folders — e.g. Geography, Biology.
Set a weekly goal
Pick a topic and how many minutes a week you'd like them to spend on it. Your student sees a friendly progress bar — no lock-outs, no pressure.
Account & billing
Checking your plan…
This device
Full screenHide the browser bars while you study.
StorageChecking storage…
Double-tap a bubble to rename itHold a bubble for moreTap one then + to addTap Select, choose a few, then drag one to move them togetherHold a bubble over another to tuck it underneathDrag from a bubble's edge to grow a new one
200000) return; if(e<0) break; i=e+8; }
{ const re=/\son[a-z]{3,20}\s*=\s*(?:"([^"]{0,2000})"|'([^']{0,2000})'|([^\s>"']{1,500}))/g; let m, g=0; while((m=re.exec(x)) && g++<400){ out+="\n"+(m[1]||m[2]||m[3]||""); if(out.length>200000) return; } } }); });
return out;
}
/* v1.71.4: how tall a review box opens: one row per line of words (a long line counts as more), never fewer than 2 or more than 6 */
function apkgRowsFor(t){ const ls=String(t==null?"":t).slice(0,4000).split("\n"); let r=0; for(let i=0;i=0) return true; } }
if(id){ const ik=String(id); if(r.ids[ik]===1) return true; const oi=r.cardIds[ik]; if(oi && oi.indexOf(hid.ord)>=0) return true; }
return false;
}
/* a picture that sits directly inside a named box and that the styling switches off */
function apkgImgDirect(inner,rs){
const re=/<(\/?)([A-Za-z][A-Za-z0-9]*)([^>]{0,2000})>/g; let depth=0, prev=null, m, guard=0; const cut=[];
while((m=re.exec(inner)) && guard++<20000){
const nm=m[2].toLowerCase();
if(m[1]==="/"){ if(!APKG_VOIDI.test(nm)) depth=Math.max(0,depth-1); continue; }
if(depth===0){
if(nm==="img"){ const src=String(apkgAttr(m[0].slice(1,-1),"src")||""); let hide=false;
for(let q=0;q=0):(src.indexOf(r.src)>=0)) hide=true; }
if(hide) cut.push([m.index,m.index+m[0].length]); prev=src; }
else prev=null;
}
if(!APKG_VOIDI.test(nm)) depth++; /* browsers read as an OPEN tag, so it still holds what follows */
}
if(!cut.length) return inner; let o="", p=0; cut.forEach(c=>{ o+=inner.slice(p,c[0]); p=c[1]; }); return o+inner.slice(p);
}
function apkgHideImgs(h,rs){
const lo=h.replace(/[A-Z]+/g,x=>x.toLowerCase()); const n=h.length; let out="", last=0, i=0, g=0; const fail=Object.create(null);
while(i])/.exec(lo.slice(lt,lt+10)); if(!m){ i=lt+1; continue; }
const tg=m[1], gt=lo.indexOf(">",lt+1); if(gt<0) break;
const cl=apkgAttr(h.slice(lt+1,gt),"class"); const mine=cl?rs.filter(r=>String(cl).split(/\s+/).indexOf(r.cont)>=0):[];
if(!mine.length || fail[tg]){ i=gt+1; continue; }
let depth=1, k=gt+1, o=-2, c=-2, end=-1;
while(true){
if(o!==-1 && o=0 && !apkgIsTagAt(lo,"<"+tg,o)) o=lo.indexOf("<"+tg,o+1); }
if(c!==-1 && c=0 && o=n) break;
let j=i; while(j=]/.test(s.charAt(j))) j++;
if(j===i){ i++; continue; }
const an=s.slice(i,j).toLowerCase(); i=j; while(i]/.test(s.charAt(k))) k++; val=s.slice(i,k); i=k; } }
if(an===name) return val;
}
return null;
}
/* ---- v1.71.2 (item 1.23): bold, italic, underline, strike-through, headings and readable colours come across from Anki ----
The plain-text pipeline below is untouched and stays the authority for every word. A second copy of the same HTML carries private-use
marker characters wherever a style starts or stops; once it has been through the same cleaning it is lined up against the plain text
letter by letter. If anything does not line up exactly, or no real letter carries a style, there is no styling at all and the card is
built exactly as it was before. Styling lives in the card's own rich text (ops), so it shows, edits and exports like text typed here. */
const RF_B=["\uE001","\uE002"], RF_B0=["\uE00B","\uE002"], RF_I=["\uE003","\uE004"], RF_I0=["\uE00C","\uE004"], RF_U=["\uE005","\uE006"], RF_S=["\uE007","\uE008"], RF_POP="\uE00A", RF_COL=0xE100;
let APKG_FAIL=false; /* set when a card's styling could not be lined up (as opposed to simply having none) */
const APKG_INH=/^(?:inherit|unset|revert|revert-layer)$/, APKG_FONT_SZ=/(?:^|\s)(?:[\d.]+(?:px|pt|em|rem|%|ex|vw|vh)|x-small|xx-small|small|medium|large|x-large|xx-large|smaller|larger)(?:\/[\d.]+[a-z%]*)?\s+\S/; /* a font shorthand is only valid with a size AND a family after it */
const APKG_FORMATTING=/^(?:a|b|big|code|em|font|i|nobr|s|small|strike|strong|tt|u)$/; /* a browser re-opens these after an implied paragraph / list-item break */
const APKG_THRU={ li:/^(?:p|div)$/, dt:/^(?:p|div)$/, dd:/^(?:p|div)$/ }; /* a new
/
/
ends the open one even through a
or
inside it */
const APKG_VOID=/^(?:br|hr|wbr|input|embed|img|source)$/, APKG_CLOSES={ p:["p"], li:["li"], dt:["dt","dd"], dd:["dt","dd"], td:["td","th"], th:["td","th"], tr:["td","th","tr"],
div:["p"], ul:["p"], ol:["p"], dl:["p"], table:["p"], blockquote:["p"], pre:["p"], section:["p"], article:["p"], center:["p"], form:["p"], nav:["p"], header:["p"], footer:["p"], figure:["p"], details:["p"],
h1:["p"], h2:["p"], h3:["p"], h4:["p"], h5:["p"], h6:["p"] }, APKG_INLINE=/^(?:b|strong|i|em|u|ins|s|strike|del|font|span|a|sub|sup|small|big|mark|code|kbd|abbr|cite)$/;
const APKG_NAMED_COL={red:"#ff0000",crimson:"#dc143c",darkred:"#8b0000",maroon:"#800000",orange:"#ffa500",darkorange:"#ff8c00",coral:"#ff7f50",gold:"#ffd700",yellow:"#ffff00",olive:"#808000",green:"#008000",darkgreen:"#006400",lime:"#00ff00",limegreen:"#32cd32",teal:"#008080",aqua:"#00ffff",cyan:"#00ffff",blue:"#0000ff",darkblue:"#00008b",navy:"#000080",royalblue:"#4169e1",purple:"#800080",indigo:"#4b0082",violet:"#ee82ee",magenta:"#ff00ff",fuchsia:"#ff00ff",pink:"#ffc0cb",hotpink:"#ff69b4",brown:"#a52a2a",chocolate:"#d2691e",tomato:"#ff6347",firebrick:"#b22222"};
/* A colour that reads on a light card AND a dark one (relative luminance between about 0.14 and 0.28, i.e. at least 3:1 against white and
against near-black). Hue and richness are kept and only the lightness is moved; black, white and greys carry no emphasis and are dropped. */
const APKG_BAND_MEMO=new Map();
function apkgBandColour(r,g,b,keep){ const mk=r+","+g+","+b+(keep?"k":""); let v=APKG_BAND_MEMO.get(mk); if(v!==undefined) return v;
v=apkgBandColourRaw(r,g,b,keep); if(APKG_BAND_MEMO.size<4096) APKG_BAND_MEMO.set(mk,v); return v; }
function apkgBandColourRaw(r,g,b,keep){
const lin=c=>{ c/=255; return c<=0.03928?c/12.92:Math.pow((c+0.055)/1.055,2.4); };
const lum=(R,G,B)=>0.2126*lin(R)+0.7152*lin(G)+0.0722*lin(B);
const hex=(R,G,B)=>"#"+[R,G,B].map(v=>{ v=Math.max(0,Math.min(255,Math.round(v))); return (v<16?"0":"")+v.toString(16); }).join("");
const L0=lum(r,g,b), LO=0.15, HI=0.28;
const mx=Math.max(r,g,b)/255, mn=Math.min(r,g,b)/255, d=mx-mn;
if(d<0.12) return keep?"":null; /* keep: a black / white / grey is a real colour that switches an outer colour OFF */
if(L0>=LO && L0<=HI) return hex(r,g,b);
const l0=(mx+mn)/2, s=d/(1-Math.abs(2*l0-1));
let h; { const R=r/255, G=g/255, B=b/255; if(mx===R) h=((G-B)/d+6)%6; else if(mx===G) h=(B-R)/d+2; else h=(R-G)/d+4; }
const rgbAt=l=>{ const c=(1-Math.abs(2*l-1))*s, x=c*(1-Math.abs(h%2-1)), m=l-c/2; let R,G,B;
if(h<1){R=c;G=x;B=0;} else if(h<2){R=x;G=c;B=0;} else if(h<3){R=0;G=c;B=x;} else if(h<4){R=0;G=x;B=c;} else if(h<5){R=x;G=0;B=c;} else {R=c;G=0;B=x;}
return [(R+m)*255,(G+m)*255,(B+m)*255]; };
const target=L0=LO-0.005 && L<=HI+0.005)?hex(R,G,B):null;
}
let APKG_OPS_LEFT=300000, APKG_ATTR_POOL=new Map(); /* per file: a ceiling on how much styling is kept, and shared attribute objects, so a hostile deck cannot eat memory */
/* a colour as written in a style or colour attribute: "" when it cannot be used (so an outer colour is switched off, not wrongly kept), null when it says nothing */
function apkgColDecl(v){
v=String(v==null?"":v).replace(/!\s*important/ig,"").trim().toLowerCase();
if(!v || v==="inherit" || v==="currentcolor" || v==="revert") return null;
const c=apkgColour(v,true); return c===null?"":c;
}
function apkgColour(v,reset){
v=String(v==null?"":v).slice(0,80).replace(/!important/ig,"").trim().toLowerCase(); if(!v) return null;
let m;
if((m=/^#([0-9a-f]{3})([0-9a-f])?$/.exec(v))) { if(m[2]!=null && parseInt(m[2]+m[2],16)<128) return null; const t=m[1]; return apkgBandColour(parseInt(t[0]+t[0],16),parseInt(t[1]+t[1],16),parseInt(t[2]+t[2],16),reset); }
if((m=/^#([0-9a-f]{6})([0-9a-f]{2})?$/.exec(v))) { if(m[2]!=null && parseInt(m[2],16)<128) return null; const t=m[1]; return apkgBandColour(parseInt(t.slice(0,2),16),parseInt(t.slice(2,4),16),parseInt(t.slice(4,6),16),reset); }
if((m=/^rgba?\(\s*(\d{1,3})\s*[, ]\s*(\d{1,3})\s*[, ]\s*(\d{1,3})\s*(?:[,\/]\s*([0-9.]+%?)\s*)?\)$/.exec(v))){
if(m[4]!=null){ const a=m[4].slice(-1)==="%"?parseFloat(m[4])/100:parseFloat(m[4]); if(!(a>=0.5)) return null; }
return apkgBandColour(Math.min(255,+m[1]),Math.min(255,+m[2]),Math.min(255,+m[3]),reset);
}
if(Object.prototype.hasOwnProperty.call(APKG_NAMED_COL,v)) return apkgColour(APKG_NAMED_COL[v]);
if(reset && /^(?:black|white|gr[ae]y|silver|dimgr[ae]y|darkgr[ae]y|lightgr[ae]y|gainsboro|whitesmoke)$/.test(v)) return "";
return null;
}
/* the bits of an inline style="" that mean something on a card: weight, slant, underline / line-through, colour */
function apkgStyleFmt(st){
const r={}, imp={}; if(!st) return r;
const set=(k,val,im)=>{ if(imp[k] && !im) return; r[k]=val; imp[k]=im; }; /* an !important declaration beats a later ordinary one */
String(st).slice(0,600).split(";").forEach(d=>{
const k=d.indexOf(":"); if(k<1) return;
const p=d.slice(0,k).trim().toLowerCase(); let v=d.slice(k+1).trim().toLowerCase();
const im=/!\s*important/.test(v); v=v.replace(/!\s*important/g,"").trim();
/* the LAST declaration wins, and "normal" / "none" switch a style OFF (Google Docs wraps pasted text in ) */
if(p==="color"){ const c=apkgColDecl(v); if(c!==null) set("col",c,im); }
else if(p==="font-weight"){ if(v==="bold"||v==="bolder"||/^[6-9]00$/.test(v)) set("b",true,im); else if(v==="normal"||v==="lighter"||v==="initial"||/^[1-5]00$/.test(v)) set("b",false,im); else if(APKG_INH.test(v)) set("b",undefined,im); }
else if(p==="font-style"){ if(v==="italic"||/^oblique\b/.test(v)) set("i",true,im); else if(v==="normal"||v==="initial") set("i",false,im); else if(APKG_INH.test(v)) set("i",undefined,im); }
else if(p==="text-decoration"||p==="text-decoration-line"){
if(v==="none"||v==="initial"||v==="unset"||v==="revert"||v==="revert-layer") { set("u",false,im); set("s",false,im); }
else if(v==="inherit") { set("u",undefined,im); set("s",undefined,im); }
else { const hu=v.indexOf("underline")>=0, hs=v.indexOf("line-through")>=0; if(hu||hs||/overline|blink/.test(v)){ set("u",hu,im); set("s",hs,im); } } }
else if(p==="font"){ /* the shorthand: only meaningful with a size; it resets weight and slant, then sets what it names */
if(APKG_FONT_SZ.test(v)){
set("b",/(?:^|\s)(?:bold|bolder|[6-9]00)(?=\s|$)/.test(v),im); set("i",/(?:^|\s)(?:italic|oblique)(?=\s|$)/.test(v),im); } }
});
return r;
}
/* what one opening tag switches on, and the matching switch-offs (in reverse order) */
function apkgFmtFor(name,tag,cols){
let on="", off=""; const add=q=>{ on+=q[0]; off=q[1]+off; };
let b=(name==="b"||name==="strong"||/^h[1-6]$/.test(name))?true:undefined, i=(name==="i"||name==="em")?true:undefined, u=(name==="u"||name==="ins"), s=(name==="s"||name==="strike"||name==="del");
let col=null;
if(name==="font"){ const c=apkgAttr(tag,"color"); if(c!=null){ const d=apkgColDecl(c); if(d!==null) col=d; } }
const sty=apkgAttr(tag,"style");
if(sty){ const f=apkgStyleFmt(sty); if(f.b!==undefined) b=f.b; if(f.i!==undefined) i=f.i; if(f.u!==undefined) u=f.u; if(f.s!==undefined) s=f.s; if(f.col!==undefined) col=f.col; }
if(b===true) add(RF_B); else if(b===false) add(RF_B0); /* a bold / italic that is switched OFF inside bold text really does switch it off */
if(i===true) add(RF_I); else if(i===false) add(RF_I0);
if(u) add(RF_U); if(s) add(RF_S);
if(col!==null){ let k=cols.indexOf(col); if(k<0){ if(cols.length>=255) return null; cols.push(col); k=cols.length-1; } on+=String.fromCharCode(RF_COL+k); off=RF_POP+off; }
return [on,off];
}
/* line the marked-up copy up against the plain words. Returns Quill-style ops, or null when anything is off. */
function apkgRichOps(rich,plain,cols,rubs){
if(!plain || !rich) return null; if(rich.length>80000){ APKG_FAIL=true; return null; }
const cnt={ u:0, s:0 }, bs=[], is=[], cs=[], rs=[], runs=[]; let buf="", p=0, any=false, bufKey="";
const bT=()=>bs.length>0 && bs[bs.length-1], iT=()=>is.length>0 && is[is.length-1]; /* bold and italic are a stack of on / off, innermost wins */
const attrs=()=>{ const kk=keyOf(); let a=APKG_ATTR_POOL.get(kk); if(a) return a;
a={}; if(bT()) a.bold=true; if(iT()) a.italic=true; if(cnt.u>0) a.underline=true; if(cnt.s>0) a.strike=true; if(cs.length && cs[cs.length-1]) a.color=cs[cs.length-1];
if(rs.length && rs[rs.length-1]) a.ruby=rs[rs.length-1].r;
if(APKG_ATTR_POOL.size<5000) APKG_ATTR_POOL.set(kk,a); return a; };
const keyOf=()=>(bT()?"b":"")+(iT()?"i":"")+(cnt.u>0?"u":"")+(cnt.s>0?"s":"")+"|"+(cs.length && cs[cs.length-1]?cs[cs.length-1]:"")+(rs.length && rs[rs.length-1]?"\u0001"+rs[rs.length-1].i+"\u0001"+rs[rs.length-1].r:"");
const flush=()=>{ if(!buf) return; const a=attrs(), last=runs[runs.length-1];
if(last && last.k===bufKey && !/^\n+$/.test(last.insert)){ last.insert+=buf; }
else { const r={ insert:buf, k:bufKey }; if(Object.keys(a).length) r.attributes=a; runs.push(r); }
buf=""; };
for(let k=0;k=0xE000 && code<=0xE1FF){
flush();
if(code===0xE001) bs.push(true); else if(code===0xE00B) bs.push(false); else if(code===0xE002) bs.pop();
else if(code===0xE003) is.push(true); else if(code===0xE00C) is.push(false); else if(code===0xE004) is.pop();
else if(code===0xE005) cnt.u++; else if(code===0xE006) cnt.u=Math.max(0,cnt.u-1);
else if(code===0xE007) cnt.s++; else if(code===0xE008) cnt.s=Math.max(0,cnt.s-1);
else if(code===0xE00A) cs.pop();
else if(code===0xE00D) rs.pop();
else if(code>=0xE020 && code<0xE100){ const rv=rubs?rubs[code-0xE020]:undefined; rs.push(typeof rv==="string" && rv?{ i:code-0xE020, r:rv }:null); }
else if(code>=RF_COL){ const c=cols[code-RF_COL]; cs.push(c===undefined?"":c); }
continue;
}
const ch=rich.charAt(k), pc=p149){ APKG_FAIL=true; return null; } /* 149 + the closing new line = at most 150 pieces per side: far more than any real card, and a hostile deck cannot hoard memory */
{ const l=runs[runs.length-1]; if(l && l.k==="|" && /^\n+$/.test(l.insert)) l.insert+="\n"; else runs.push({ insert:"\n", k:"|" }); }
return runs.map(r=>{ const o={ insert:r.insert }; if(r.attributes) o.attributes=r.attributes; return o; });
}
/* ---- v1.90.0 (1.23) Japanese readings: a reading over a word.
Three sources reach one form: Anki's furigana filter / a element (apkgRubyTags), a word followed by its reading in square brackets 漢字[かんじ] or in
brackets 話(はなし) (apkgRubyText, kanji base + kana reading only, so ordinary brackets are never touched). Each becomes a private marker group
(U+E302 word U+E303 reading U+E304) that survives the text cleaner; apkgRubyFlat turns it into either plain "word(reading)" (when the styled twin cannot be
carried) or the bare word (when the twin carries the reading as a ruby attribute on those letters). The reading is plain text: at most 40 letters,
no control or direction characters, at most 200 readings in one field. Anything else about a element (tags inside, a long word) is left to the old path. ---- */
const APKG_K="\\u3400-\\u4DBF\\u4E00-\\u9FFF\\uF900-\\uFAFF\\u3005-\\u3007", APKG_KR="\\u3041-\\u3096\\u309D\\u309E\\u30A1-\\u30FA\\u30FC-\\u30FE\\u30FB\\u3000 ";
const APKG_RB_BR=new RegExp(" ?(["+APKG_K+"]{1,12})\\[(["+APKG_KR+"]{1,40})\\]","g");
const APKG_RB_PA=new RegExp("(["+APKG_K+"]{1,12})[(\\uFF08](["+APKG_KR+"]{1,40})[)\\uFF09]","g");
const APKG_RB_CTRL=/[\u0000-\u001F\u007F-\u009F----]/g;
function apkgRubyText(s,bud){
if(s.length<4 || !/[㐀-䶿一-鿿豈-々-〇]/.test(s) || !/[\[((]/.test(s)) return s;
const parts=s.split(/(\[sound:[^\]]{0,500}\])/i); /* a file name such as [sound:話(はなし).mp3] is never a reading */
const mk=(m,b,r)=>{ r=r.replace(/\s+/g," ").trim(); if(!r || bud.n<=0) return m; bud.n--; return ""+b+""+r+""; };
for(let k=0;kx.replace(/&/g,"&").replace(//g,">");
const pairRe=/^([^<]{0,200}?)(?: