Silabario is book 9 of ABC Books and the first one in Spanish: the Silabario de los niños puerto-riqueños, printed in Puerto Rico in 1861. It's the red book at the end of the shelf at https://abcbooks.fyi/readers/. Kids learn to read Spanish the way the book teaches it: syllables first (ba, be, bi, bo, bu), then two-syllable words (ca-sa, ma-no), then short stories, then longer readings about Puerto Rico. The app itself is rebuilt in the Primer post. Every block has a Copy button.
The book
- Silabario de los niños puerto-riqueños: libro primario de lectura, Imprenta de Acosta, Puerto Rico, 1861. The text comes from the archive.org scan of the Library of Congress copy (public domain).
- It's built in temas (syllable charts), ejercicios (words split into syllables) and lecturas (readings). The preface says the child should never spell letter by letter: they learn each syllable as one sound, "al primer golpe de vista" (at first sight).
- The first 13 lessons print every word split into syllables ("Ro-sa u-na ni-ña bo-ni-ta"). The app keeps that on screen, and the voice says the whole word. Lecturas 14 to 20 are normal text.
- The readings: kids' stories (Rosa and her kitten, Juanito's toys, the good boy and the bad boy), Christopher Columbus in five parts, Puerto Rico's geography in 1861, Juan Ponce de León, two priests remembered for helping the poor and for teaching (Padre Giménez, Padre Rufo), and Alejandro Ramírez, who rebuilt the island's economy after 1813.
- Other old Spanish readers on archive.org were looked at too: the Arnold series (Silver Burdett, adapted for Spanish-speaking kids by Manuel Fernández Juncos, about 1900) and Cyr's Libro primero/segundo (Ginn, 1899). Those are good next books.
Built by someone who doesn't speak Spanish
Nobody in the house speaks Spanish, so nothing here relies on someone listening and judging the Spanish:
- The text was copied by hand from the page images, page by page, not from the scan's computer-read text (which turns "niño" into "ni-iio" and "á" into "ii").
- The voice is the same Eloquence engine as the English books, switched to its Latin American Spanish voice (
esm.syn). The grown-up only picked how it sounds: Reed, speed 60 (normal) and 75 (faster), syllables said one at a time with a short pause (they tried three styles; running the syllables together with commas sounded odd). - The pronunciation is checked by a machine: every reading line is played into a Spanish speech recognizer (Vosk), first with no hints ("free listening"), then the way the app's reading check uses it. A word the recognizer hears right by itself was said clearly.
- Every screen shows a one-line English meaning of the lesson for the grown-up, and the 1861 teacher notes are shown with their meaning.
Gentle words, or the original 1861 words
A book from 1861 says things we wouldn't tell kids today. The app has both versions of every reading that was changed: the gentle one by default, and the original under Spanish stories: original 1861 words on the home screen (a grown-up's switch, saved on the device). The changes, in English:
- Lectura 1: the girl's mother was "the lame one, silly and foolish", then "the mute one" and "the crazy one". Now: "the neighbor, pretty and healthy", "the aunt", "the neighbor"; "Lola the bad one" is "Lola the little girl".
- Lectura 2b: naughty Felipe "will die", and his mom "scolds and hits him". Now he "will end up alone", and she "scolds him a lot". The ending "nobody will cry for him" is left out.
- Lectura 4: "the invalid islander ... will perish" is "the sick islander ... must rest".
- Lectura 5: "the inept child" is "the child"; "God will not love you" is left out; "love the invalid" is "love the sick"; "flee the bad child and fear him like a strong poison" is "don't copy the bad child".
- Lectura 6: unkind children "are abandoned by the angels, suffer until they die and don't go to heaven". Now "the angels are sad and hope they'll be good one day".
- Lectura 10: the Queen's slurs about Muslims ("the brutish Saracen", "the contemptible Muslim") are left out; she still prays to see the cross over Granada.
- Lectura 12: "she stood there like a fool" is "she kept quiet". Lectura 13: "how calm the madman is today" is "how calm the dog is today". Lectura 18: "the impertinence of the insane" is "the complaints of the sick".
- Lectura 15 (the Taíno): the original calls the Taíno "savages" who "didn't know who God was", calls Ponce de León "very humane", and ends "the Indians disappeared". The gentle version tells how the Taíno lived (villages, yuca and corn, canoes, hammocks, clay pots), that the Spanish forced them to work, that war and diseases from Europe made them very few, and that many Puerto Rican families have Taíno grandparents and we still say Taíno words (hamaca, canoa, huracán).
- Lectura 16: the census "counting whites, mulattoes and blacks" is just the number of people. Lectura 17: Ponce de León "killed the cacique personally" and was sent "to punish the Carib Indians who stole Indians to eat them" (an old story used to justify enslaving them). Both are left out; the Florida people "defended their land".
- Word drills: the syllable "sex" (Tema 5) and the old insult "ca-fres" (Ejercicio 8) are left out.
The original text of each changed paragraph stays in the source file as a % line right above the gentle one.
Modern spelling
The 1861 spelling leaves out accents that change how a word is said ("tenia" is TE-nia, "tenía" is te-NÍ-a; "corazon" vs "corazón"), and uses "á" for "a", "fué", "esclamó", "Goatemala". The converter fixes those from a list (modern-es.json) plus rules (-ción, the vosotros endings -áis/-éis), on screen and in the voice, keeping the syllable hyphens ("te-ní-a"). Lesson 13 is about the accent mark itself ("número, numero, numeró"), so its drill words stay as printed. Abbreviations are written out (U. = usted, D. = don).
How it plays
206 lessons: the vowels, the alphabet in three parts (1861 Spanish has ch, ll and ñ as letters), 53 syllable-chart lessons, 60 word drills and 73 readings.
- Syllable charts: up to 25 syllables per lesson, 15 of them as cards ("ba", "be"...). Each card says its syllable on its own; a chart row is said one syllable at a time.
- Word drills: 18 words, 12 as cards, shown split ("ca-sa"); the voice says "casa".
- Readings: lines of at most about 70 letters (the 1861 sentences are long, so they're cut at commas or before "y", "que", "de"), with up to 10 new words as cards.
- Press and hold a word to sound it out the Spanish way: the word big on screen, each syllable lit and said in turn ("ga... ti... to"), then the whole word.
- Instructions in English and Spanish (added 15OCT2026): in this book every spoken instruction is said in English, then in Spanish ("New words. Tap each card to hear it." then "Palabras nuevas. Toca cada tarjeta para oírla."), the step title and the hint text show both, and so do "Find the word" ("Busca la palabra"), "Write" ("Escribe"), "Lesson 5" ("Lección 5") and "Great job" ("¡Muy bien!"). A grown-up's button on the home screen, Spanish book instructions, switches between English + Spanish, English only, and Spanish only (for when the kids know the routine). The English books are not affected. The translations are in
instructions-es.json(the child is "tú", the grown-up "usted"); the 30 sentences were checked with the Spanish recognizer like the book (91% of words heard; the misses were small words like "el", "y", "a"). - Tracing and blackboard write á é í ó ú ñ ü, the capitals, and ¿ ¡: the fonts don't have them, so
trace.jsadds the accent as one more stroke, sized to the letter (in cursive the í is short, and a full-size accent made it look like an "l"). - The reading check (🎤) uses the Spanish model on CT 307 (abc-speech). See the next section.
The Spanish reading check
CT 307 (abc-speech) runs Vosk. It now has a second model, vosk-model-small-es-0.42 (40 MB download, 58 MB on disk), and the app adds &lang=es for this book. Measured with the book's own recordings (936 lines, 7,258 words):
- the app's way (the line's words as the grammar): 84% of words heard; free listening: 69%.
- Most misses are a run of words in one line, where the recognizer lost its place, not single words said wrong. Short lines do much better; lines of 10 to 20 seconds did worst (the first version, with sentences up to 200 letters, got 79%, so the lines were cut shorter).
- The small Spanish model hears a word-start m as b (mesa as besa, mala as bala), even though the recordings clearly start with different sounds (the grown-up listened). In Spanish, b and v are the same sound anyway. So the server treats m, b and v as one letter for Spanish, both when picking look-alike words and when matching what it heard.
- Because it's weaker than the English model, a Spanish line counts as read when 3 of 4 of its words were heard.
Rebuild
You need the app from the Primer post (its C:\ABCBooks folder on the PC, CT 306 (abc-web) for the site) and the reading check from the abcbooks.fyi post (CT 307 (abc-speech)). Eloquence must be installed on the PC with its Spanish voices (C:\Windows\Speech\Eloq\esm.syn).
1. Files
Proxmox host shell:
mkdir -p /root/abcbooks-spanish
cd /root/abcbooks-spanish
Paste each block into the Proxmox host shell. Each one writes one file into /root/abcbooks-spanish/.
silabario1861.txt: the whole book, hand-copied from the page images. = KIND n (pages) title starts a part; @pic and @ are the picture and English meaning; % is the original 1861 wording of the next paragraph; > is a teacher note from the book; # lines are notes.
cat > /root/abcbooks-spanish/silabario1861.txt <<'EOF'
# Silabario de los niños puerto-riqueños (Puerto Rico, Imprenta de Acosta, 1861), archive.org silabariodelosni00vjld
# Hand-transcribed from the page images (book page = PDF page - 6). Spelling kept as printed (1861):
# "á" as a word = modern "a"; "ma-ma"/"pa-pa" = mamá/papá; "pa-ge" = paje. Modernizing happens in the converter.
# Format: "= TEMA n" / "= EJERCICIO n" / "= LECTURA n" start a part; syllable rows are one row per line.
# "% <text>" = the ORIGINAL 1861 wording of the paragraph that follows it (parents can switch to it in the app);
# the next plain paragraph is the gentle default. "# ..." lines are notes, ignored by the converter.
# "> <text>" = a note to the teacher printed in the book (kept for the grown-up, not read to the child).
= VOCALES (page 7) Vocales, vocales salteadas (vowels, then mixed up)
A E I O U
a e i o u
E U A O I
i o u e a
= ALFABETO (pages 7-8) Alfabeto mayúsculo y minúsculo, y salteado (the 1861 alphabet has ch, ll and ñ as letters)
A B C Ch D E F G H I J K L Ll M N Ñ O P Q R S T U V X Y Z
a b c ch d e f g h i j k l ll m n ñ o p q r s t u v x y z
B C A M P Q Z R Ch O E F S G N Ll Ñ J I U K D V H L T Y X
a d c b e s f h g j l k m ch v q ll x y z t u r o i n p ñ
= TEMA 1 (pages 9-11)
Ba Bu Bi Bo Be
bi bo be ba bu
Du Do Di Da De
di du de do da
Fo Fa Fi Fe Fu
fi fo fu fe fa
Ja Ju Ji Je Jo
ji jo je ja ju
Cha Cho Che Chi Chu
chi chu che cha cho
Pu Pi Po Pa Pe
pi po pu pe pa
Mu Mo Ma Me Mi
me ma mu mi mo
Ca Co Ce Cu Ci
ce co cu ca ci
To Ta Te Tu Ti
ti to ta te tu
Li La Lu Lo Le
lo la le lu li
Ha Hi Ho Hu He
ho hu ha he hi
Na Nu Ni Ne No
ni no na ne nu
Ñi Ño Ñu Ña Ñe
ña ñu ñi ñe ño
Vu Va Vo Vi Ve
ve vi va vu vo
Su So Sa Si Se
si se su sa so
= EJERCICIO 1 (pages 11-12)
Bo-ba bu-ba be-be pe-pe bo-bo chu-pa de-do mi-ra ni-ña da-le fa-ma. Lo-la co-me ma-ma vi-ve ti-to pu-ja su-be hi-jo pa-sa so-so chi-cha ce-na co-sa ma-la vi-ve mu-cho co-co ma-lo ni-ño chi-co ca-sa lo-co mo-mo to-ca fa-fa mu-cha ga-na di-ce. Lo-pe fo-fa ha-ce mo-fa di-le do-lo da-le lo-do le-la he-no su-sa hi-go cha-to to-ca to-cha co-la lo-ca fi-ja fa-ja pi-pa pe-so fa-cha vo-to so-pa pa-so ma-no bo-ca ca-bo ce-ja fa-da to-do du-da lu-to mi-co mo-no chu-lo po-lo pa-ge ma-ña.
= LECTURA 1 (page 12)
@pic 🪙
@ Lolo's little coin is under the table. He gives it to his mom to pass on to Pepa, the neighbor's little girl. Lola, who eats a lot and helps nobody, is watching.
# EDITED (user chose option 2, 2026-10-14): the 1861 text mocks women as lame/silly/mute/crazy. Replacements use only
# Tema 1 syllables: co-ja, le-la y bo-ba -> ve-ci-na, bo-ni-ta y sa-na | la mu-da -> la ti-a
# Lo-la la ma-la -> Lo-la la chi-ca | la ni-ña de la lo-ca -> la ni-ña de la ve-ci-na
% Lo-lo tu-vo su cha-vo chi-co ba-jo de la me-sa de su pa-pa Pe-pe, y le di-jo á su ma-ma, to-ma ma-ma mi cha-vo, y da-lo á Pe-pa la ni-ña chi-ca hi-ja de la co-ja, le-la y bo-ba, da-le ca-fé y de to-do, di-me si ba-ja le da-ré mi cha-vo á la mu-da, y tu un pe-so, no se pa-ga na-da Lo-la la ma-la, ta-te ya ba-ja ma-ma, y Lo-la me ve, te-mo mu-cho su mi-mo, se la pa-sa so-la: co-me mu-cho, y no ha-ce na-da ni da á la ni-ña de la lo-ca.
Lo-lo tu-vo su cha-vo chi-co ba-jo de la me-sa de su pa-pa Pe-pe, y le di-jo á su ma-ma, to-ma ma-ma mi cha-vo, y da-lo á Pe-pa la ni-ña chi-ca hi-ja de la ve-ci-na, bo-ni-ta y sa-na, da-le ca-fé y de to-do, di-me si ba-ja le da-ré mi cha-vo á la ti-a, y tu un pe-so, no se pa-ga na-da Lo-la la chi-ca, ta-te ya ba-ja ma-ma, y Lo-la me ve, te-mo mu-cho su mi-mo, se la pa-sa so-la: co-me mu-cho, y no ha-ce na-da ni da á la ni-ña de la ve-ci-na.
= TEMA 2 (pages 12-13)
Go Gu Ge Gi Ga
gi go ga gu ge
Ko Ka Ki Ke Ku
ki ko ka ku ke
Lla Lli Lle Llo Llu
llu lla lli lle llo
Ra Ro Ru Re Ri
ro ru ri ra re
Yi Yu Ya Yo Ye
ye yi yo yu ya
Za Zo Zi Zu Ze
zo ze za zi zu
Xa Xo Xu Xe Xi
xo xu xa xi xe
Gue Ga Gui Go Gu
gui go gue gu ga
Que Qui Ca Co Cu
qui que co cu ca
= EJERCICIO 2 (page 14)
Ca-lla so pi-llo lle-ga se-lla bu-lla ra-yo ga-lla lla-ga pu-lla ra-ra ro-sa sa-ra to-se se-to ra-za pu-ra za-pa pa-za ga-to to-ga co-me ra-ta ru-so pu-ro ti-ra yu-ca ri-ma su-ya tu-yo cu-yo re-so go-la go-zo so-ga to-ga ga-la la-go mo-le ga-go ve-ga lla-no ca-lle po-zo ri-fa la-na mi-lla mi-llo lla-ma ma-ya mi-lla chu-zo pi-sa tu-sa co-sa ce-lo lo-za ta-sa sa-to tu-na na-ta fo-ro to-ro ro-to pu-ro ri-ta pi-so fu-ga ga-fa.
= LECTURA 2 (pages 14-15)
@pic 🐱
@ Rosa is a good girl who minds her mom. She is gentle with her kitten, feeds it bit by bit and hugs it, and the whole house loves her.
Ro-sa u-na ni-ña bo-ni-ta no ha-ce na-da ma-lo, ha-ce ca-so á su ma-ma, y su pa-pa no pe-ga á su ga-ti-to y lo a-ma mu-cho, y le da pe-da-ci-to á pe-da-ci-to su co-mi-da: si llo-ra su mi-zu co-mo e-lla lo lla-ma le di-ce, mi ga-ti-to bo-ni-to, y lo mi-ma, lo be-sa y lo co-ge co-mo á su ni-ñi-to. To-da la ca-sa a-ma, y re-ga-la, y a-do-ra á Ro-si-ta, y se le da de to-do lo que pi-de.
= EJERCICIO 2b (pages 15-16, "Continuacion del ejercicio 2.°")
> (En virtud de las dificultades que presentan á los niños las sílabas gue gui por su irregularidad y las cuales confunden fácilmente con ge gi, hemos creido oportuno presentar este egercicio. El maestro esplicará al niño que la u de gue gui no suena, y que esa sílaba debe pronunciarse como ga go, es decir, como si no existiera la u.)
# Teacher note meaning: gue/gui are irregular and get mixed up with ge/gi; the u in gue/gui is silent.
Ca gue ge gi gui gi go gue co cu zu se sa za zi zo so ja ge gue ge gui ji gi go ju ja ga gue ge gui gi ji.
Co-sa co-ci-do zi-za-ña co-ra-za ca-sa-be co-no-ce pe-re-ce ca-be-za co-co cu-cha-ra ce-da ca-si-lla qui-ja-da co-la-da bo-que-te pa-que-te gui-sa-do pa-ga que-ri-do co-mi-co ca-mi-sa ba-que-ta co-ce ce-co ce-na na-za ce-no que-so zo-que-te qui-zo cu-la-ta ga-na pa-ge pa-gue pi-que-te gu-la ga-na-do go-lo-so a-gu-ja ma-go go-ma hi-go gui-ña-da ga-za-po ma-la-gue-ta ge-ta a-gui-la a-mi-go se-gui-do jo-ye-ro go-lle-te.
= LECTURA 2b (pages 16-17, "Continuacion de la lectura 2.°")
@pic 😠
@ Felipe is a naughty, spoiled boy who brags about his rich dad and hits the cook's little son. If he keeps this up, he will end up alone.
# EDITED (option 2): "se mo-ri-rá" (he will die) -> "se que-da-rá so-lo" (he will end up alone); mom "lo ri-ñe y le pe-ga"
# (scolds and hits him) -> "lo ri-ñe mu-cho" (scolds him a lot); last sentence (nobody will cry for him) shortened.
% Que ni-ño ma-lo el hi-jo de a-que-lla el lla-ma-do Fe-li-pe: su ma-ma lo ri-ñe ca-da ho-ra, no sa-be na-da, que se-rá de e-se hi-jo, si si-gue a-si se mo-ri-rá, no re-za, di-ce to-do lo que ve y lo que no ve, no va á mi-sa, le pe-ga al hi-jo pe-que-ño de la co-ci-ne-ra, y lo ha-ce ca-da ra-to. E-se ni-ño va-no y ma-lo di-ce: yo me lla-mo Fe-li-pe Que-sa-da, hi-jo de pa-pa ri-co, que me da mu-chí-si-mo di-ne-ro y ju-gue-te. Yo no a-mo á Fe-li-pe, su ma-ma lo ri-ñe y le pe-ga; pe-ro no se me-jo-ra y si-gue ma-lo, y ha-ce u-na lo-cu-ra ca-da ra-to. Si si-gue a-si de se-gu-ro se mo-ri-rá, y no lo llo-ra-rá ni su ma-ma, ni su pa-pa.
Que ni-ño ma-lo el hi-jo de a-que-lla el lla-ma-do Fe-li-pe: su ma-ma lo ri-ñe ca-da ho-ra, no sa-be na-da, que se-rá de e-se hi-jo, si si-gue a-si se que-da-rá so-lo, no re-za, di-ce to-do lo que ve y lo que no ve, no va á mi-sa, le pe-ga al hi-jo pe-que-ño de la co-ci-ne-ra, y lo ha-ce ca-da ra-to. E-se ni-ño va-no y ma-lo di-ce: yo me lla-mo Fe-li-pe Que-sa-da, hi-jo de pa-pa ri-co, que me da mu-chí-si-mo di-ne-ro y ju-gue-te. Yo no a-mo á Fe-li-pe, su ma-ma lo ri-ñe mu-cho; pe-ro no se me-jo-ra y si-gue ma-lo, y ha-ce u-na lo-cu-ra ca-da ra-to. Si si-gue a-si, de se-gu-ro se que-da-rá so-lo.
= TEMA 3 (pages 17-18) DIPTONGOS
Ae Ay Ao Ai Au
au ae ay ao ai
Eo Ey Eu Ea Ei
eu eo ey ea ei
Ia Ie Io Iu
io iu ia ie
Oa Oy Oe Oi Ou
oe oa oy ou oi
Ua Ue Uy Uo Ui
uy uo ua ui ue
= EJERCICIO 3 (page 18)
Hoy voy cae hay ria bie sea leo lea veo coi ley may doi mio mia pio tio fue gua gai fui lia lio geo cia jau cio doi soi pia bay via vie viu nia nio nui veo vea pou duo rio mio gie ciu cua cuo sio sie bue buo bua rey fea feo tea vea tia boy fui reo dia bay fio doi muy buo cau noi jua cia cie mue pie dio pia pua lue mue loa sea nea gia gio seu duo rei nio pai liu due zue nie tie lui mey soy voy duo sau jue hue hui tue cua cuo chue sue sua lue llue dios.
= LECTURA 3 (page 19)
@pic 🎠
@ Juanito went to the pier. His uncle gave him toys: a wooden pony and a toy soldier who can move his foot, head and hand. His uncle promises a little boat too.
# footnote on ye-güi-ta: "El maestro explicará la significacion de la diéresis." (the teacher explains the two dots on ü)
# "sua-vo" = zuavo, a toy Zouave soldier; "mi-nie" = Minié rifle (a toy); "fa-lua" = falúa, a small boat.
Jua-ni-to fue hoy al mue-lle de pa-seo, su tio le dió u-na pie-za nue-va muy bo-ni-to jue-go, u-na ye-güi-ta de ma-de-ra, y su sua-vo que tie-ne su foe-te, lue-go tie-ne su bue-na ca-ra-bi-na mi-nie y su ba-yo-ne-ta. Su sua-vo va ha-cia u-na rí-a muy cau-da-lo-sa, y mue-ve su pie de-re-cho y la ca-be-za y la ma-no. Que ai-ro-so sua-vo tie-ne Jua-ni-to, su tio se lo dió de dia, y de-cia que le da-ria u-na fa-lua y u-na ca-noi-ta he-cha de hue-vo hue-co va-cio.
= TEMA 4 (pages 19-21) Combinaciones inversas
Od ad id ed ud
Do da di de du
Al ul il ol el
La lu li le lo
Am im um om em
Ma mi mu me mo
Ar ir er or ur
Ra ri re ro ru
En in on an un
Ne ni na no nu
Ap ip up op ep
Pa pi po pe pu
As es is os us
Si se su so sa
Ib ob ab eb ub
Bi bo bu be ba
Ag ig ug og eg
Ga go ge gi
Et it at ot ut
Te ti to tu ta
Ac oc uc ic ec
Ca co cu ci ce
Ah eh ih oh uh
Ho he hi hu ha
Af ef uf if of
Fa fu fe fo fi
Az oz ez iz uz
Zo za zu ze zi
Ex ix ux ox ax
Xa xe xi xo xu
= EJERCICIO 4 (pages 21-23)
Al-ba ab-ju-ra ic-te-ri-cia in-cau-to es-ca-ño ol-fa-to iz-qui-er-do un-gi-do ar-bo-le-da am-pa-ro em-be-le-co or-de-na-do ob-ce-ca al-bi-no im-pá-vi-do em-ba-te ex-pe-di-to as-ti-lla es-ca-ma ar-ga-ma-sa us-ta-ri ul-ce-ra im-po-li-ti-co an-cho ig-no-ro am-bi-guo op-ti-mo al-ja-ba ip-so-la or-ga-no en-ca-ge os-cu-ro in-va-li-do ol-vi-do is-le-ño ab-so-lu-to es-pa-ña on-du-ra et-ce-te-ra al-me-ja en-ce-ra-do al-to ur-na ar-go om-ni-mo-do ac-to or-di-na-rio on-ce es-pe-so im-pu-di-co es-pe-jo or-de-na-do ad-mi-ra-do op-ti-ca in-te-ri-no um-be-la in-dio an-cho os-cu-lo is-la as-ma-ti-co ul-ti-mo im-pe-rio ad-mi-ra-do ig-no-ro er-pe-ti-co es-tu-dio-so ob-ce-ca-do al-ta-ne-ro am-bo or-bi-ta in-va-di-do un-tu-ra ag-na-do al-bo-ra-da al-ma ac-ce-so is-lo-te ab-di-ca em-bo-no ar-ma am-po-lla as-no ec-ti-co em-ba-la-ge in-ca as-pe-ro em-ba-ja-da em-be-le-so en-cia ex-cu-sa ex-po-si-to ob-ge-to im-pe-tu im-pe-di-do un-do-so ur-ca ex-ce-di-do ex-cep-to ex-ci-ta-do.
= LECTURA 4 (page 23)
@pic 🏫
@ Go to school, where wisdom is. Careful on the waxed stairs. Ustari, a sick islander, waits on the bench; he is very ill and must rest.
# EDITED (option 2): "in-vá-li-do" (invalid, an old word for a disabled person) -> "en-fer-mo" (sick);
# "y pe-re-ce-rá" (and he will perish) -> "y de-be des-can-sar" (and he must rest).
Ni-ño id á la es-cue-la, que a-llí es-tá la sa-bi-du-ría, se or-de-na-do. Cui-da-do á la de-re-cha de la úl-ti-ma es-ca-le-ra, el pi-so es-tá en-ce-ra-do y el pa-sa-di-zo obs-cu-ro.
% Dio-ni-sio, a-lli es-tá el is-le-ño in-vá-li-do lla-ma-do Us-ta-ri, ya es-tá ex-pe-di-to pa-ra ir á tu la-do has-ta la es-cue-la, ya lla-ma y es-pe-ra en el es-ca-ño an-cho, es-tá as-má-ti-co, tie-ne ic-te-ri-cia, mí-ra-le la ór-bi-ta de su o-jo, pa-re-ce ín-di-go ig-no-ro lo que pa-de-ce es-tá im-pe-di-do, tie-ne u-na úl-ce-ra muy ma-la, se po-ne la un-tu-ra es-pe-sa que le dio el óp-ti-co; pe-ro se ex-po-ne, es muy as-pe-ra, y le cau-sa-ba am-po-lla, tie-ne die-ta ab-so-lu-ta, y es-tá im-pá-vi-do, se ol-vi-da de su es-ta-do, es muy in-cau-to, se ha ex-ce-di-do y pe-re-ce-rá.
Dio-ni-sio, a-lli es-tá el is-le-ño en-fer-mo lla-ma-do Us-ta-ri, ya es-tá ex-pe-di-to pa-ra ir á tu la-do has-ta la es-cue-la, ya lla-ma y es-pe-ra en el es-ca-ño an-cho, es-tá as-má-ti-co, tie-ne ic-te-ri-cia, mí-ra-le la ór-bi-ta de su o-jo, pa-re-ce ín-di-go ig-no-ro lo que pa-de-ce es-tá im-pe-di-do, tie-ne u-na úl-ce-ra muy ma-la, se po-ne la un-tu-ra es-pe-sa que le dio el óp-ti-co; pe-ro se ex-po-ne, es muy as-pe-ra, y le cau-sa-ba am-po-lla, tie-ne die-ta ab-so-lu-ta, y es-tá im-pá-vi-do, se ol-vi-da de su es-ta-do, es muy in-cau-to, se ha ex-ce-di-do y de-be des-can-sar.
= TEMA 5 (pages 24-25)
# EDITED (option 2): the syllable "sex" is left out of the gentle chart (row 10).
Bad bed bid cec coc cig fec
Cad ced cid jad duc dor
Fed fid fic fac gad dad
Did ged gid jud jid jed
Dod dig jac lad led deg
Lid lac lec lic lig mad luc
Mag mid med mix nad
Ned nid nac pad pid ped
Pac pic pug rad red rid nec
% Ñed ñad nid sad sec sex
Ñed ñad nid sad sec
Sig seg sub tad ted tid tac
Tec tex vad ved vid vic blad
Lled llid yud yed yid yac
Yec zad zed zid zac reg rec doc
= EJERCICIO 5 (pages 25-26)
Ce-bad sig-no be-bed di-vi-did ce-ded co-no-ced re-ci-bid sig-no cig-za-que in-cad a-du-cid pe-re-ced vi-vid mo-fad doc-to-ra-do a-fec-to fac-tu-ra fic-ti-cio a-bo-gad ca-ri-dad di-vi-did co-ged e-xi-gid de-jad te-ged dog-ma dig-ni-dad jac-ta-do a-mo-lad mo-led in-dem-ni-zad pu-lid lac-ta-do e-lec-to lig-ni-to ma-lig-no lec-tu-ra a-mad mag-ni-tud tu-mid co-med mix-tu-rad pug-nad te-ned u-nid in-sec-to o-cu-pad mi-rad pa-red mo-rid en-se-ñad ta-ñed po-sad sex-to sig-na-tu-ra sub-si-dio i-mi-tad tác-ti-ca téc-ni-co tex-tu-ra ca-vad mo-ved vi-vid vic-to-ria ca-llad mu-llid a-po-yad in-yec-to mi-rad in-ter-reg-no di-rec-to pac-to luc-tuo-so e-du-cad de-cid ne-gad pu-lid e-qui-dad te-med cer-ce-nad en-ga-ñad te-ñid em-pe-zad sen-tid me-ted ar-did rec-ti-tud.
= LECTURA 5 (page 27)
@pic 🐞
@ Love God and live well. Isaac, leave that insect alone and have pity on it. Be kind to the sick, and don't copy the bad child.
# EDITED (option 2): "El ni-ño i-nep-to" (the inept child) -> "El ni-ño"; "ved que se mue-re y Dios no os a-ma-rá"
# (see, it is dying, and God will not love you) -> "ved que se mue-re"; "in-vá-li-do" -> "en-fer-mo" (sick);
# "Hu-ye al ni-ño ma-lo, an-da so-lo y te-mé-lo co-mo un ve-ne-no muy ac-ti-vo" (flee the bad child, fear him like
# a strong poison) -> "No i-mi-tes al ni-ño ma-lo" (don't copy the bad child).
% Ni-ño a-mad á Dios, te-med-le y vi-vid co-mo el di-ce. El ni-ño i-nep-to que no es-tu-dia su lec-tu-ra, la ig-no-ran-cia se-rá su a-mi-ga. Y-sac de-jad e-se in-sec-to, mi-rad que no se mue-ve, con-si-de-rad que mo-ri-ria, te-ned pie-dad de el, a-yu-dad-lo, ved que se mue-re y Dios no os a-ma-rá. La vic-to-ria dig-na es la de la vir-tud. Te-ned ca-ri-dad, a-mad al in-vá-li-do, re-ci-bid-lo de buen mo-do, so-cor-red-lo y Dios os a-ma-rá co-mo á un hi-jo que-ri-do. Hu-ye al ni-ño ma-lo, an-da so-lo y te-mé-lo co-mo un ve-ne-no muy ac-ti-vo.
Ni-ño a-mad á Dios, te-med-le y vi-vid co-mo el di-ce. El ni-ño que no es-tu-dia su lec-tu-ra, la ig-no-ran-cia se-rá su a-mi-ga. Y-sac de-jad e-se in-sec-to, mi-rad que no se mue-ve, con-si-de-rad que mo-ri-ria, te-ned pie-dad de el, a-yu-dad-lo, ved que se mue-re. La vic-to-ria dig-na es la de la vir-tud. Te-ned ca-ri-dad, a-mad al en-fer-mo, re-ci-bid-lo de buen mo-do, so-cor-red-lo y Dios os a-ma-rá co-mo á un hi-jo que-ri-do. No i-mi-tes al ni-ño ma-lo.
= TEMA 6 (pages 27-...)
Ban bes bas bin bos bis bun bus bor bon bul bal bar bel ben
Cas cor cen can cis con ces cus cun cer col cur cin cer cor cal cul car
Dan des den din dos dun dar dir del dul don dor der dal
Fas fen fis fon fes fus fur fan fin fil fos for fal fer far
Gan ges gin gis gos gen gun guen guin gon gil gus gor gir gul gur gar
Chan chas ches chis chen chin chus chos chun chon cher char chal chul
Jas jen jan jis jes jon jus jer jil jun jor jol jin jos jal jir jar
Kan kin kos kir kol
Las lan len lar lis len lon lin lus los ler lun lil lur lir lor lal lel
Llan llas llen lles llin llis llos llun llur llar llil llor llon llal llir ller
Man mas men mis min mon mes mun mos mur mus mil mor mar mal mer
Nas nan nen nun nus nes nor nos nis nin nal ner nur nol nil non
Ñan ñes ñin ños ñun ñen ñil ñor ñun ñol ñel ñar ñon
Pan pes pin pos pen pun pas pil pon pul per pus par pel pir pol pis por
Quen quis quin quel quil
Ras ren res ris ran ros rus ron rir rul rin ror run ral rer rar
San ses sin sus sos sen sun sor son sir ser sul sol sas sar ser
Tas tus ten tes tin tos ton tis tun tan tal tul tel til tar tur tor tir tol ter
Van ves ven vin vas vos vun von ver vir vis vol var vur vor val
Yas yen yan yis yes yon yen yus yos yal yir yul yol yel
Zan zes zin zos zun zar zel zor zel zur
= EJERCICIO 6 (pages 31-33)
Bar-bi-quin cor-co-bar des-vir-tu-ar sen-tir las-ti-mo-sos bor-de-jar bir-lo-chos pe-no-les pa-los bul-tos cur-tir tos-to-nes sus-pi-rar cer-ce-nar o-jal llan-ten qui-zas len-te-jue-las ca-llos ga-lli-na-zos gor-go-jos mu-llir con-tu-maz men-ti-ro-sos a-ni-mal pe-re-gil dul-ces bar-bon chi-qui-llos mon-tan-tes con-tan-tes so-nan-tes ver-gon-zan-tes car-ca-ñar per-pi-ñan bu-ril te-nor do-lor en-ga-ñan a-ñil es-pa-ñol de-fen-sor a-pun-ta-dor pes-ta-ñar pin-char chan-ce-lar lan-ce-ros chin-ches sal-chi-chon sos-pe-chas chis-mes mas-ca-ras mis-mos bo-ri-quen can-di-dez con-di-men-tos tin-tas ti-ras tu-yos su-yos cu-yas cu-yos tan-tos ton-tos ten-gas tin-te-ros po-yal e-ne-mis-tar ven-gan-zas vin-di-car es-tor-bar de-vol-ver pu-jan-zas or-de-nan-zas co-ra-zon sin-ra-zon co-men-zar a-pun-tar dis-po-ner san-ti-guar ben-de-cir fir-mar es-po-ner mur-mu-rar.
= LECTURA 6 (page 33)
@pic 👼
@ Good children share their bread with the poor and respect old people, and the angels protect them. When children are unkind, the angels are sad and hope they will be good one day.
# EDITED (option 2): the ending "los an-ge-les hu-yen de e-llos, los de-jan so-los en el mun-do, ... has-ta que mue-ren
# al fin, y no van al cie-lo" (the angels run from them, leave them alone, troubles until they die and don't go to
# heaven) -> "los an-ge-les se po-nen tris-tes, y es-pe-ran que un dí-a se-an bue-nos" (the angels are sad and hope
# that one day they will be good).
% Los ni-ños bue-nos ben-di-cen á Dios, dan su pan á los men-di-gos in-fe-li-ces que van con a-fan de rin-con en rin-con, y pi-den con llan-to en los o-jos al-gu-na co-sa que co-mer. Dios y los san-tos ben-di-cen á los ni-ños bue-nos que se com-pa-de-cen del men-di-go, y res-pe-tan á los an-cia-nos, e-llos los ven des-de el cie-lo, y cuan-do tie-nen buen co-ra-zon los an-ge-les ba-jan y los lle-van de la ma-no, y los sal-van de to-dos los ma-les mun-da-nos sin que nin-gu-na in-fe-li-ci-dad les ven-ga nun-ca; pe-ro á los ni-ños ma-los que nun-ca re-zan, que se mo-fan de los vie-jos, y les ti-ran y ha-cen mal-da-des y no les dan li-mos-nas, ni un pe-da-zo de pan con que a-li-men-tar-se y ha-cen a-de-mas mu-chas co-sas ma-las, los an-ge-les hu-yen de e-llos, los de-jan so-los en el mun-do, y des-de en-ton-ces les su-ce-den mil sin-sa-bo-res has-ta que mue-ren al fin, y no van al cie-lo don-de es-tá Dios y la Vír-jen, y to-dos los an-ge-les y los san-tos.
Los ni-ños bue-nos ben-di-cen á Dios, dan su pan á los men-di-gos in-fe-li-ces que van con a-fan de rin-con en rin-con, y pi-den con llan-to en los o-jos al-gu-na co-sa que co-mer. Dios y los san-tos ben-di-cen á los ni-ños bue-nos que se com-pa-de-cen del men-di-go, y res-pe-tan á los an-cia-nos, e-llos los ven des-de el cie-lo, y cuan-do tie-nen buen co-ra-zon los an-ge-les ba-jan y los lle-van de la ma-no, y los sal-van de to-dos los ma-les mun-da-nos sin que nin-gu-na in-fe-li-ci-dad les ven-ga nun-ca; pe-ro á los ni-ños ma-los que nun-ca re-zan, que se mo-fan de los vie-jos, y les ti-ran y ha-cen mal-da-des y no les dan li-mos-nas, ni un pe-da-zo de pan con que a-li-men-tar-se y ha-cen a-de-mas mu-chas co-sas ma-las, los an-ge-les se po-nen tris-tes, y es-pe-ran que un dí-a se-an bue-nos.
= TEMA 7 (pages 34-35)
Bla blu ble bli blo
Blun blon blan blen blin
Cla cli clo clu cle
Clin clun clan clen clon
Pla plo pli plu ple
Plon plin plan plen plun
Fla flo fli flu fle
Flin flon flen flun flan
Gla glo gli glu gle
Glin glan glen glon glun
Tla tli tlo tlu tle
Tlin tlon tlun
Plot club flec flic flux plos plus plat plet flar
= EJERCICIO 7 (pages 35-36)
Ha-bla ble-do blo-que-os ta-bli-llas blan-da-men-te a-ma-ble sa-ble blon-dos cla-si-fi-ca-ble cle-tos cla-vos te-cla an-clan flo-ge-dad a-fli-gi-dos ten-tar a-fluen-tes fla-men-cos re-glas ren-glon glu-ti-nan-tes man-gle man-glar flan-cos flan-des in-flar flo-tar fle-xi-bi-li-dad fluc-tuar pla-ta-nal plan-tel es-plin plu-ma-zos pla-zas di-plo-ma-cia ple-nos du-plo su-plir in-flu-jos an-gla-das glo-ton glán-du-las in-gle-sas cle-men-tes an-glo-sa-jon a-tle-tas club Ma-drid Lón-dres a-dre-de a-tlan-ti-cos.
= LECTURA 7 (page 36)
@pic 🎩
@ A funny tale: a skinny show-off arrives claiming he can teach every language. When classes start, everyone sees he can't even hold a pen.
# A funny tale about a show-off who claimed to teach every language and turned out to be a fraud. Kept as printed.
Os voy á ha-cer un cuen-te-ci-to, a-ten-ded. A Tlas-cal-ta-la lle-gó clan-des-ti-na-men-te un fla-men-co con un buen plan; e-ra muy fla-co, pe-ro muy in-fla-do de or-gu-llo, y tra-jo un ra-glan blan-cuz-co, de-cia pú-bli-ca-men-te que ha-bla-ba con flui-dez to-da cla-se de i-dio-mas con ad-mi-ra-bles re-glas; se lla-ma-ba Pa-blo Tri-ble-do, y di-jo que ve-nia del mon-te blan-co á es-ta-ble-cer u-na ad-mi-ra-ble cla-se pa-ra en-se-ñar fluen-te-men-te á pla-ti-car to-da cla-se de i-dio-mas; a-flu-ye-ron mu-chos ni-ños y se-ño-ri-tas, y al co-men-zar las cla-ses se a-cla-ró que el se-ñor fla-men-co no e-ra mas que un com-ple-to flo-jo, un sim-plon que in-du-da-ble-men-te lo e-cha-ron de Lón-dres y Ma-drid, y que so-lo e-ra ad-mi-ra-ble en ser glo-ton, que se co-mia has-ta los pla-tos, las per-so-nas in-flu-yen-tes lo o-bli-ga-ron á con-fe-sar de pla-no, que no e-ra mas que un tra-pa-lon, y que no sa-bí-a ni co-ger la plu-ma.
= TEMA 8 (pages 37-38)
Bra bru bro bre bri
Brin brun bren bron bran
Cra cro cru cri cre
Crin cron crun cren cran
Dri dra dre dro dru
Drun drin dron dren dran
Fra fri fro fru fre
Frun fran frin fron fren
Gra gro gri gru gre
Grin gron grun gren gran
Pra pro pre pri pru
Prin pron prun pren pran
Tra tre tro tru tri
Tron trin tren tran trun
Cons trac prag prog cuns drac crip crad truc
= EJERCICIO 8 (pages 38-39)
# EDITED (option 2): "ca-fres" (an old insulting word for people from southern Africa) is left out of the gentle list.
% Bra-vo gran bri-bon brin-can trom-pa-da bron-car a-bren cro-que-tas trun-cas fi-bras tran-qui-las cru-ces cre-ta cro-mo lu-cren dra-gon a-dre-de cir-cuns-tan-cia po-dri-dos dro-me-da-rio dru-sos pa-dron pu-dren fra-gan-tes fri-vo-li-dad fro-ta-cio-nes fru-ga-li-dad ca-fres frun-ci-do a-za-fran fran-qui-cia in-frin-gir a-fren-to-sos cons-tan-tes fron-do-sos gra-cio-sos cons-pi-ra-dor a-gre-ga-dos gri-mas gro-se-ria a-gru-par-se grin-go mon-te-ne-gron gran-de-zas mu-gren com-pras pre-me-di-tas pri-ma-ve-ras pru-sia-no pro-yec-to prín-ci-pe pron-ti-tud em-pren-de-dor tra-mi-tar tre-mo-los tro-ton tri-pli-ca-dos in-tru-sos trun-ca-do trom-pos qui-trin trin-che-ras in-frac-cion prag-má-ti-ca ins-crip-to ins-truc-cion cons-tan-cia.
Bra-vo gran bri-bon brin-can trom-pa-da bron-car a-bren cro-que-tas trun-cas fi-bras tran-qui-las cru-ces cre-ta cro-mo lu-cren dra-gon a-dre-de cir-cuns-tan-cia po-dri-dos dro-me-da-rio dru-sos pa-dron pu-dren fra-gan-tes fri-vo-li-dad fro-ta-cio-nes fru-ga-li-dad frun-ci-do a-za-fran fran-qui-cia in-frin-gir a-fren-to-sos cons-tan-tes fron-do-sos gra-cio-sos cons-pi-ra-dor a-gre-ga-dos gri-mas gro-se-ria a-gru-par-se grin-go mon-te-ne-gron gran-de-zas mu-gren com-pras pre-me-di-tas pri-ma-ve-ras pru-sia-no pro-yec-to prín-ci-pe pron-ti-tud em-pren-de-dor tra-mi-tar tre-mo-los tro-ton tri-pli-ca-dos in-tru-sos trun-ca-do trom-pos qui-trin trin-che-ras in-frac-cion prag-má-ti-ca ins-crip-to ins-truc-cion cons-tan-cia.
= LECTURA 8 (page 40) Christopher Columbus, part 1
@pic ⛵
@ Christopher Columbus, part 1: born in Genoa, Italy, a brave sailor who dreamed of finding a new world.
Gre-go-rio, Cle-men-ti-na y Brau-lio lla-men á Fran-cis-co y pre-sén-ten-se-me, por-que os voy á pro-nun-ciar la his-to-ria de un gran hom-bre: cuá-dren-se con pron-ti-tud. Prin-ci-pia-ré di-cién-do-les que en u-na bri-llan-te ciu-dad lla-ma-da Gé-no-va, en la pre-cio-sa I-ta-lia, na-ció un gran hom-bre que fué des-pues muy re-nom-bra-do por sus em-pre-sas a-som-bro-sas. Dis-tin-guió-se des-de el prin-ci-pio por su in-tre-pi-dez en na-ve-gar el bra-vo mar, su nom-bre e-ra Cris-to-bal Co-lon. Ya ha-cí-a mu-chos a-ños que na-ve-gan-do po-bre-men-te so-bre las em-bra-ve-ci-das o-las, ar-ros-tran-do to-das las des-gra-cias de su po-bre vi-da, pre-me-di-ta-ba con tran-qui-li-dad y cons-tan-cia en la in-con-ce-bi-ble em-pre-sa de des-cu-brir o-tras tier-ras, ó me-jor di-cho, o-tro mun-do: es-te e-ra su gran pro-yec-to.
= LECTURA 8b (pages 40-41, "Continuacion de la lectura 8.°")
@pic ⛵
@ Columbus, part 2: he was poor and needed ships. Kings, scholars and even children laughed at him, but he kept his dignity and went to Spain.
Mu-chos con-tra-tiem-pos su-frió el sá-bio Cris-to-bal Co-lon des-pues que a-ban-do-nó su no-ble car-re-ra pa-ra bus-car pro-tec-cion y rea-li-zar su gran pro-yec-to, por-que el in-fe-liz e-ra muy po-bre, y pa-ra rea-li-zar sus pla-nes ne-ce-si-ta-ba mu-cho di-ne-ro pa-ra com-prar bu-ques, y al-qui-lar ma-ri-ne-ros; de mo-do, que el des-gra-cia-do an-da-ba de hom-bre en hom-bre, de cór-te en cór-te, ex-po-nien-do y es-pli-can-do sus pla-nes bri-llan-tes, y sus cor-rec-tos pre-sa-gios, se pre-sen-tó á gran nú-me-ro de per-so-na-ges y to-dos se bur-la-ban de su cre-du-li-dad, los mis-mos cé-le-bres as-tró-no-mos y geó-gra-fos que com-pren-den es-tas co-sas lo cre-ye-ron un des-gra-cia-do lo-co, y lo com-pa-de-cian u-nos, y o-tros lo des-pre-cia-ban; pe-ro Co-lon lle-no de fé y no-ble dig-ni-dad, sin do-blar su pre-cio-sa fren-te ba-jo el tris-te pe-so de las des-gra-cias, se pre-sen-tó á los prin-ci-pa-les re-yes im-plo-ran-do su pro-tec-cion, co-sa im-po-si-ble, por-que lo cre-í-an un lo-co, y o-tros lo a-bru-ma-ban con pre-gun-tas y des-pro-pó-si-tos, y has-ta los bri-bo-nes in-su-fri-bles chi-qui-llos lle-ga-ron á ti-rar-le y mo-far-se de él. Co-lon su-frió con no-ble-za y pru-den-cia to-dos los con-tra-tiem-pos que le so-bre-vi-nie-ron, y siem-pre fi-jo en su pro-yec-to, se di-ri-gió á Es-pa-ña pre-sin-tien-do que a-llí en-con-tra-ría la pro-tec-cion que bus-ca-ba.
= TEMA 9 (pages 42-44) Diptongos y triptongos articulados
# footnote on güey: "El maestro explicará la significacion de la diéresis."
Bria tial cuar cais caus cion cier suer
suel sias mais nais neis nues lais luis
cian bian sion liar hiel nuez nais neis
bien bion luen piad peis pial niar niel
rial riel ruin reis rias bais bias biar
pues bies guas grias guan gues quel ques
cuos real rual dian dien dion fian fien
fion fiar fiel rue jais jeis ruis sois
duer nuer cuer fuer dies ties tuas tion
cuan juan tuan dios brio vias vien viar
vios vial mios mien mias mier mues plei
plie prie prue flau fuer dial duos tuer tais lien zais
Aea eai iai iei uay uey uie uao
Tiais diais quiais quieis guieis quien ciais cueis
nieis mieis cueo buey viais vieis güey güeis
guais bieis biaos nuais nueis fieis miais liais
fiais buais bueis piais fieis diais dieis tuais
tueis tieis cuais lieis luais lueis niais giais
gieis riais jiais jieis
= EJERCICIO 9 (pages 44-45)
Quien Dios a-guan-teis seu-dó-ni-mos da-reis mi-rais go-zais sep-tua-gé-si-mo can-sais coin-ci-den-cias per-tur-bais a-ni-qui-lais a-griais dis-tin-gais muer-te co-lum-piad tra-bais con-su-mais cam-bieis pa-ra-guas rui-no-so duer-men cuer-pos me-dieis i-mi-teis per-pe-tuas co-la-cion i-ni-ciais e-di-fi-queis va-ciais en-vi-diais a-ca-ri-cieis i-mi-teis ven-gueis ver-güen-za ven-guen a-ve-ri-güeis.
= LECTURA 9 (pages 45-46) Columbus, part 2: before Queen Isabella
@pic 👑
@ Columbus, part 3: guided by a priest, he kneels before Queen Isabella and asks her to trust him and his science.
Cris-to-bal Co-lon sa-bí-a de-ma-sia-do que Es-pa-ña po-seia un Rey, y u-na Rei-na muy gran-des por sus pen-sa-mien-tos y cui-da-dos en bien del pais que re-gian; tam-bien sa-bí-a que a-que-llas cir-cuns-tan-cias no e-ran las mas a-de-cua-das pa-ra o-fre-cer-les sus pro-yec-tos y de-man-dar-les su va-lio-so a-po-yo y pro-tec-cion; por-que la na-cion se o-cu-pa-ba en-tón-ces en ha-cer la guer-ra á los mo-ros, y lan-zar-los pa-ra siem-pre del pais pa-ra en-gran-de-ci-mien-to y glo-ria de la pa-tria; sin em-bar-go, te-nia gran-des es-pe-ran-zas y pre-sen-ti-mien-tos, y na-da le de-tu-vo has-ta que guia-do por un sa-bio sa-cer-do-te lla-ma-do Mar-che-na pu-do e-char-se por se-gun-da vez á los pies de la sa-bia Rei-na di-cién-do-le a-sí: Se-ño-ra, qui-zás me to-ma-reis por un lo-co, os vais á cre-er que mi-rais un vi-sio-na-rio, ya es-toy des-pres-ti-gia-do an-te Vues-tra Ma-ges-tad y ha-beis oi-do men-cio-nar los des-va-ríos del des-gra-cia-do Co-lon, no me creais tal, gran Se-ño-ra; no es á un vi-sio-na-rio al que es-cu-chais, no te-mais que os en-ga-ñe mi fan-ta-sía; quie-ro so-lo que me es-cu-cheis y os fieis de mí; oid-me por pie-dad, y si me dais pro-tec-cion, en cam-bio ten-dreis el nue-vo mun-do que en-tre-veo au-si-lia-do por me-dio de mis es-tu-dios y es-pe-rien-cias con-tí-nuas, con-fiad en la cien-cia de un hom-bre de bien, quien no pue-de en-ga-ña-ros, y es-ten-de-reis vues-tro po-de-río al o-tro la-do del o-cé-a-no, y lle-va-reis a-llí las cre-en-cias de vues-tros a-bue-los, con la cier-ta re-li-gion del Dios del Cie-lo: fiaos de mí, Se-ño-ra, vos li-diais hoy por la re-li-gion cier-ta del Cris-to, con-ti-nuad vues-tra mi-sion há-cia don-de mi cien-cia au-si-lia-da por la di-vi-na Pro-vi-den-cia me ar-ras-tra, no re-hu-seis mi rue-go, Se-ño-ra, y ja-más os ar-re-pen-ti-reis, pues pron-to se-reis due-ña de nue-vas tier-ras.
= TEMA 10 (pages 47-48)
Blas bles blos bren bler brad brid bras bres bris
Bros brus brar brir braz brez briz bral bril
Clad clas cles clos clar crar drar drir dril dras
dres dris drid drad flad fler flor flas fles flos
fras fres frus fros fris glad trar per frir frur
glas gles glos grar gras gres gris gros ples plos
plas plis plus plad pled plid tral tril trar trer
trur tror tras tres tris tros trus traz triz trez
troz truz prar prad pras pres pris pros frid frir frar
= EJERCICIO 10 (pages 48-49)
Tiem-blas ha-ble vo-ca-blos ha-blar tem-blar a-cos-tum-brar mez-clar cu-brid cu-bras cu-bres bris-ca hom-bres sem-brar po-drás a-brir lom-briz in-fles a-bril um-bral mez-clad nú-cleo mez-clas po-drir cua-dril ma-dras pu-dres cua-dros Ma-drid a-cen-drad in-flad chi-flar in-flos Fras-qui-ta fres-co frus-tra-do su-fris ar-re-glad re-glas in-gles si-glos san-grar de-ni-grar ar-re-glos a-graz graz-nar gris sue-gras ne-gras a-gres-te cum-ples com-plos plás-ti-cos cum-plis non-plus-ul-tra tem-plad cum-plid tea-tral a-tril en-trar de-mos-trar pos-trer nu-trir tris-tes mi-nis-tros a-troz a-ves-truz mo-triz com-prar ci-frar su-frir com-prad com-pras pres-cin-dir Pris-co pros-pec-to su-frid frus-trar frus-le-ría te-clas mez-cles me-drar mien-tras tras-po-ner.
= LECTURA 10 (page 50) Columbus, part 3: the Queen answers
@pic 👑
@ Columbus, part 4: the Queen believes him. Spain is at war and poor, but she will sell her jewels if needed to give him ships. He leaves weeping with joy.
# EDITED (option 2): the Queen's words "has-ta que des-tro-za-da la sa-crí-le-ga me-dia lu-na del sar-ra-ce-no
# em-bru-te-ci-do, bri-lle so-bre la in-fiel Gra-na-da ... di-si-pan-do las ne-gras ti-nie-blas del in-cré-du-lo y
# des-pre-cia-ble mu-sul-man" (until the sacrilegious crescent of the brutish Saracen is smashed ... the contemptible
# Muslim) -> "has-ta que bri-lle so-bre Gra-na-da la bri-llan-te cruz de nues-tra re-den-cion".
% Tem-bla-ba no-ble-men-te el in-fe-liz é in-tré-pi-do Cris-tó-bal Co-lon so-bre sus tré-mu-las ro-di-llas, y lle-no de tris-te-za do-bla-ba su bri-llan-te fren-te a-guar-dan-do la pri-mer pa-la-bra de la gran Rei-na. Le-van-taos, le di-jo es-ta con no-ble a-cen-to; creo en vues-tros pro-nós-ti-cos, soy cris-tia-na, la san-gre de mis mas bra-vos hi-jos cor-re hoy con pro-fu-sion en de-sa-gra-vio de nues-tra glo-rio-sa fé, y se-gui-rá cor-rien-do has-ta que des-tro-za-da la sa-crí-le-ga me-dia lu-na del sar-ra-ce-no em-bru-te-ci-do, bri-lle so-bre la in-fiel Gra-na-da la bri-llan-te cruz de nues-tra re-den-cion, di-si-pan-do las ne-gras ti-nie-blas del in-cré-du-lo y des-pre-cia-ble mu-sul-man.
Tem-bla-ba no-ble-men-te el in-fe-liz é in-tré-pi-do Cris-tó-bal Co-lon so-bre sus tré-mu-las ro-di-llas, y lle-no de tris-te-za do-bla-ba su bri-llan-te fren-te a-guar-dan-do la pri-mer pa-la-bra de la gran Rei-na. Le-van-taos, le di-jo es-ta con no-ble a-cen-to; creo en vues-tros pro-nós-ti-cos, soy cris-tia-na, la san-gre de mis mas bra-vos hi-jos cor-re hoy con pro-fu-sion en de-sa-gra-vio de nues-tra glo-rio-sa fé, y se-gui-rá cor-rien-do has-ta que bri-lle so-bre Gra-na-da la bri-llan-te cruz de nues-tra re-den-cion.
La pa-tria su-fre hoy gra-ves des-ca-la-bros, nues-tros mas flo-ri-dos re-gi-mien-tos y es-cua-dro-nes se diez-man, nues-tras ar-cas fla-quean, yo es-toy po-bre; pe-ro no obs-tan-te, na-da me ar-re-dra, y si pre-ci-so fue-re sa-cri-fi-ca-ré mis mas pre-cia-das pren-das, ven-de-ré mis pe-dre-rías pa-ra pro-te-ger vues-tros de-sig-nios, yo no du-do que el Dios cle-men-te pre-mia-rá vues-tros tra-ba-jos y sa-cri-fi-cios, al tras-por-tar á o-tros cli-mas sus san-tas doc-tri-nas; re-ti-raos tran-qui-lo, y os pro-me-to que ten-dreis u-na flo-ti-lla, y cuan-to mas ne-ce-si-teis pa-ra rea-li-zar la em-pre-sa. La a-le-gría bri-lló por pri-me-ra vez en el gran-de y sen-si-ble co-ra-zon de Cris-tó-bal Co-lon, y se re-ti-ró der-ra-man-do lá-gri-mas de pla-cer y de gra-ti-tud.
= LECTURA 10b (page 51, "Continuacion de la lectura 10") Columbus, part 4: 1492
@pic 🌎
@ Columbus, part 5: three ships, the Pinta, the Niña and the Marigalante, sail from Palos on August 3, 1492 and reach new lands on October 12: America, where Puerto Rico is.
Cum-plió la Rei-na I-sa-bel, lla-ma-da la Ca-tó-li-ca, la pa-la-bra que ha-bia da-do á Co-lon el cual in-fa-ti-ga-ble se pre-pa-ró á rea-li-zar su mis-te-rio-so via-ge pa-ra des-cu-brir o-tro mun-do. Co-lon ad-qui-rió tres bu-ques que se nom-bra-ban la Pin-ta, la Ni-ña y la Ma-ri-ga-lan-te, y pro-vis-tos de sus cor-res-pon-dien-tes tri-pu-la-cio-nes, pro-vi-sio-nes, é ins-tru-men-tos cien-tí-fi-cos, re-ci-bió de la Rei-na el nom-bra-mien-to de Al-mi-ran-te, y des-pues de pos-trar-se á las plan-tas de su pro-tec-to-ra pa-ra tri-bu-tar-le gra-cias por sus gran-des fa-vo-res, re-ci-bió el man-do de la es-cua-dri-lla, y en me-dio de los gri-tos en-tu-sias-tas del pue-blo sa-lió del puer-to de Pa-los el dia tres de A-gos-to del a-ño de mil cua-tro-cien-tos no-ven-ta y dos, y des-pues de su-frir mil tro-pie-zos y des-gra-cias, des-cu-brió las pri-me-ras tier-ras del mun-do que sa-lió á bus-car, el dia do-ce de Oc-tu-bre del mis-mo a-ño y las cua-les, por ser ver-da-de-ra-men-te un nue-vo mun-do lle-van e-se nom-bre, y tam-bien el de A-mé-ri-ca que es en don-de es-tá la pre-cio-sa is-la de Puer-to-Ri-co en que vi-vi-mos no-so-tros.
= TEMA 11 (page 52)
Ax ex ix ox ux
axi exa exi exo exu oxa
oxi exel exac oxal exep exor
= EJERCICIO 11 (pages 52-53)
A-xio-ma e-xac-ti-tud ex-ce-len-tí-si-mo e-xi-gir e-xo-ne-rar e-xu-be-ran-te o-xa-la-to o-xi-da-cion o-xí-ge-no e-xi-gen-te ex ab-rup-to e-xac-cion ex-ci-tar ex-trac-to o-xal-me ex-clu-si-va-men-te ex-á-ni-me ex-cel-so ex-cu-sa-do e-xá-men ex-cep-cion e-xí-guo e-xa-ge-ra-cion e-xor-dio ex-pur-gar ex-tre-mi-dad ex-tra-va-gan-te a-xi-lar o-xi-da-cion o-xa-li-de o-xi-za-cre.
= LECTURA 11 (page 53) Sayings
@pic 📜
@ Short sayings: be exact, don't exaggerate or lie; study every day; speak calmly; follow your parents' advice; don't ask of others what you wouldn't ask of yourself.
Sé e-xac-to en lo que di-ces, la e-xa-ge-ra-cion es mé-nos e-xe-cra-ble que la men-ti-ra; pe-ro la so-cie-dad e-xi-gen-te las con-de-na á am-bas.
Si quie-res ob-te-ner un buen é-xi-to en tus e-xá-me-nes, sé pun-tual con tus lec-cio-nes dia-ria-men-te.
No te ex-ce-das nun-ca en tus pa-la-bras, ni te ex-ci-tes al ha-blar, am-bas co-sas a-car-rean dis-gus-tos.
Los con-se-jos de tus pa-dres son ex-ce-len-tes pres-crip-cio-nes que de-bes ob-ser-var, has-ta que ex-ha-les tu úl-ti-mo a-lien-to.
No e-xi-jas de los o-tros, lo que no pue-das e-xi-gir-te á tí mis-mo.
= TEMA 12 (pages 54-55) Letras cuyo sonido se confunde
> (*) El maestro tendrá el mayor cuidado en que el discípulo pronuncie esta leccion con la mayor perfeccion.
# Teacher note meaning: these letters sound alike (b/v, s/z/c, ll/y, l/r); make sure the child says them very carefully.
Ba va be ve bi vi bo vo bu vu
Sa za ce se ze ci si zi so zo su zu
Lla ya lle ye lli yi llo yo llu yu
Al ar el er il ir or ol ur ul
az as ez es iz is oz os uz us
= LECTURA 12 (pages 55-56, "Lectura y ejercicio XII")
@pic 👂
@ Sounds that are easy to mix up (b/v, s/z/c, ll/y, l/r). Elvira goes quiet around old Benito; being poor is no shame; a chatty girl; a mason; sun and sky; help the old and the poor.
# EDITED (option 2): "se que-da-ba co-mo u-na bo-ba" (she stood there like a fool) -> "se que-da-ba ca-lla-da" (she kept quiet).
% El-vi-ra can-ta-ba cuan-do i-ba á vi-si-tar al ve-ne-ra-ble Be-ni-to que vi-via bien vis-to por to-dos los ve-ci-nos; pe-ro no a-bria la bo-ca ni de-cia un vo-ca-blo, y se que-da-ba co-mo u-na bo-ba cuan-do lle-ga-ba don-de él, y oia su voz de bú-ho.
El-vi-ra can-ta-ba cuan-do i-ba á vi-si-tar al ve-ne-ra-ble Be-ni-to que vi-via bien vis-to por to-dos los ve-ci-nos; pe-ro no a-bria la bo-ca ni de-cia un vo-ca-blo, y se que-da-ba ca-lla-da cuan-do lle-ga-ba don-de él, y oia su voz de bú-ho.
Ce-ci-lia, Za-ra y Su-sa-na, di-cen que se rien del po-bre si va mal ves-ti-do, qui-zás no sa-ben que la po-bre-za es hi-ja de Dios, y que va-le mas á sus o-jos ir sin za-pa-tos, y me-re-cer su gra-cia que lle-var sun-tuo-sos tra-ges, y te-ner un co-ra-zon da-ña-do por la en-vi-dia y los ce-los: la po-bre-za no ha-ce des-me-re-cer á na-die, y va-le mas ser po-bre vir-tuo-so y pa-cí-fi-co, que no ri-co zi-za-ñe-ro y zo-que-te: es me-jor que di-gan, zu-ta-ni-to el hi-jo del za-pa-te-ro es un po-bre-ci-to; pe-ro muy jui-cio-so, que di-gan, fu-la-ni-to el hi-jo del ri-ca-cho es un per-ver-so.
Ya se ca-lla la chi-qui-lla del ye-se-ro, des-de que lle-gué a-yer es-tá char-lan-do, yo no com-pren-do por que los chi-qui-llos se han de mez-clar en las con-ver-sa-cio-nes de los ma-yo-res, a-llí es-tá en la ca-lle en a-yu-nas muy llo-ro-sa, por-que la man-dó ca-llar el yer-no del pa-ya-so, y no re-pa-ra en la llu-via ya que no lle-va su sa-ya de ba-ye-ta.
A-bur, co-ge el baul del ur-ba-no que voy á mar-char, sal, em-pie-za á an-dar, de-so-cu-pa el lo-cal. El don-cel de a-yer pi-de la miel pa-ra be-ber. El al-ba-ñil va á ir á bru-ñir el pre-til y em-pie-za á pe-dir su per-nil.
Ya sa-le el sol, pa-re-ce de ar-re-bol: que ca-lor, da-me el qui-ta-sol peor del es-pa-ñol ma-yor, y a-pa-ga el fa-rol del co-me-dor. El cie-lo es-tá a-zul por el sur, el vul-go se bur-la del vien-to nor-te. Haz la paz con tu e-ne-mi-go si e-res ca-paz de a-mar-lo nue-va-men-te, y se-rás ge-ne-ro-so. Si la ve-jez te pi-de al-gu-na vez su au-xi-lio, dá-se-lo, na-da es mas be-llo que ver á un jó-ven dan-do su a-po-yo á un an-cia-no. El in-fe-liz por-dio-se-ro pi-de en nom-bre de Dios, y e-sa in-vo-ca-cion de-be a-ca-tar-se co-mo de-be-mos es-cu-char su voz.
Je-sus es-pi-ran-do en la cruz es un fa-ro de bri-llan-te luz que de-be guiar-nos há-cia él, á tra-vés de las an-gus-tias é in-jus-ti-cias de la chus-ma mun-da-nal.
= EJERCICIO 12b (pages 57-58) Quintisílabos y sestisílabos (five- and six-syllable words)
Al-bo-ro-ta-dor be-ren-je-na-zo cla-vi-cím-ba-lo cha-pe-to-na-da des-am-pa-ra-dos es-pec-tá-cu-los fal-si-fi-ca-dor gu-ber-na-ti-vo ho-no-rí-fi-cos in-fe-cun-di-dad ju-di-ca-tu-ra li-be-lá-ti-cos llo-ro-sa-men-te ma-la-ven-tu-ran-za ne-gli-gen-tí-si-mo os-ten-to-sa-men-te per-pen-di-cu-lar-men-te que-re-llo-sa-men-te re-ge-ne-ra-ti-vo su-per-in-ten-den-te tem-po-ra-li-za-dos u-ni-ver-sal-men-te vi-ce-pre-si-den-te za-par-ras-tro-so.
Ad-ve-ni-mien-to be-ne-vo-len-cia cir-cuns-tan-cia-do chi-chis-vea-do-res de-pra-va-cio-nes es-pe-cia-li-dad frau-du-len-ta-ron glo-rio-sí-si-ma he-te-ro-gé-neos ir-re-li-gio-si-dad ju-ris-pru-den-cia le-ga-li-za-cion llu-vio-sa-men-te mor-ti-fi-ca-cio-nes no-ti-fi-ca-cio-nes o-be-de-ci-mien-to pa-tri-mo-nia-li-dad quie-tí-si-ma-men-te re-em-pla-za-mien-to si-mul-tá-nea-men-te trans-fi-gu-ra-cio-nes u-su-ra-ria-men-te vic-to-rio-sí-si-mo yus-ta-po-si-cio-nes ze-lo-sí-si-ma-men-te.
= LECTURA 12b (page 59) Puntuación (punctuation)
@pic ✍️
@ What each punctuation mark is for: comma, semicolon, colon, period, question mark, exclamation mark, dots, quotation marks, parentheses and dash.
> (*) El maestro señalando la coma, dos puntos &c. hará que el niño diga como se llama cada signo.
# Teacher note meaning: point to each mark and have the child say its name.
La co-ma sir-ve pa-ra se-pa-rar las par-tes mas pe-que-ñas de la o-ra-cion.
El pun-to y co-ma se-pa-ran las par-tes ma-yo-res.
Los dos pun-tos sir-ven pa-ra se-pa-rar las fra-ses que tie-nen re-la-cion en-tre sí.
El pun-to se-pa-ra en-te-ra-men-te u-na fra-se de o-tra.
La in-ter-ro-ga-cion es el sig-no de las pre-gun-tas.
La ad-mi-ra-cion se u-sa en las es-cla-ma-cio-nes.
Los pun-tos sus-pen-si-vos sig-ni-fi-can que se in-ter-rum-pe el dis-cur-so.
Las co-mi-llas in-di-can que lo que es-tá den-tro de e-llas lo di-jo o-tro que no es-tá pre-sen-te.
El pa-rén-te-sis sir-ve pa-ra en-cer-rar pa-la-bras ó fra-ses que son in-de-pen-dien-tes del dis-cur-so prin-ci-pal.
El guion sir-ve en los diá-lo-gos pa-ra se-pa-rar lo que ca-da cual di-ce.
= EJERCICIO 13 (pages 60-61) Acentuación (the accent mark changes the word)
Número numero numeró
Habito habitó hábito
Cítara citara citará
Rótulo rotulo rotuló
Solido solidó sólido
Picará picara pícara
Específico especificó especifico
Cálculo calculo calculó
Criticó crítico critico
Circulo circuló círculo
Cántara cantara cantará
Líquido liquido liquidó
Pacifico pacificó pacífico
Incomodó incómodo incomodo
= LECTURA 13 (pages 61-62)
@pic 🦜
@ Words that change meaning with the accent mark (número, numero, numeró), in short funny scenes: a cheeky girl selling a parrot, a pharmacist, a critic, a calm dog.
# EDITED (option 2): "Que pa-cí-fi-co es-tá hoy el lo-co" (how calm the madman is today) -> "el pe-rro" (the dog).
Yo mis-mo nu-me-ro y ro-tu-lo mis e-fec-tos, por-que el pí-ca-ro que me los nu-me-ró y ro-tu-ló tu-vo tan mal cál-cu-lo, que pu-so muy lí-qui-da la tin-ta, y se bor-ró el nú-me-ro y el ró-tu-lo.
Ha-bi-to en el mis-mo a-po-sen-to que a-quel crí-ti-co que ha-bi-tó el pi-so pri-me-ro, y que lle-va-ba un há-bi-to par-do.
Cal-cu-lo que el in-ge-nie-ro cal-cu-ló mal la o-bra, por-que en su cál-cu-lo no in-clu-yó la cí-ta-ra, se le ci-ta-rá o-tra vez, y lo re-for-ma-rá: e-se hom-bre no a-tien-de á su o-bli-ga-cion, y se la pa-sa con la cí-ta-ra en la ma-no.
¡Que pí-ca-ra es la mu-cha-cha que me ven-dió la co-tor-ra! Le pre-gun-té si pi-ca-ba, por-que no que-ria com-prar un a-ve que pi-ca-ra, y me con-tes-tó con mu-cha des-fa-cha-tez "llé-ve-la Ud., y le res-pon-do que no le pi-ca-rá, es muy man-si-ta la pi-ca-ro-na."
¿Qué es-pe-cí-fi-co es es-te? yo he so-li-do com-prar-lo lí-qui-do y só-li-do; pe-ro te-nia o-tro ró-tu-lo: se cir-cu-ló que e-ra no-ci-vo.
Cri-ti-co á Juan, co-mo él me cri-ti-có en un tiem-po por-que no leia bien: en es-to no ha-go mas que pa-gar-le en la mis-ma mo-ne-da, me cri-ti-có, y hoy lo cri-ti-co yo.
Se di-ce que á Juan le han des-pe-di-do de la es-cue-la por de-sa-pli-ca-do ¡que ver-güen-za! yo no cir-cu-lo e-sa no-ti-cia; en cier-to cír-cu-lo muy re-ser-va-do se ha di-cho, y en re-ser-va lo di-go, no sea co-mo An-to-nio que cir-cu-ló a-que-lla no-ti-cia que re-sul-tó fal-sa.
La mu-cha-cha que me ven-dió la cán-ta-ra tie-ne u-na voz an-ge-li-cal, a-llí vie-ne ¡cuán-to se a-le-gra-rian us-te-des que can-ta-ra! me en-can-ta su voz, ya se a-cer-ca, y creo que can-ta-rá por-que trae el sem-blan-te muy a-le-gre.
¿Qué ha-ce us-ted se-ñor bo-ti-ca-rio?—A-mi-go lo que us-ted ve, li-qui-do el con-te-ni-do de es-ta bo-te-lla, que se me ha pues-to só-li-do; es un lí-qui-do muy ma-ja-de-ro, y me da mu-cho tra-ba-jo, a-yer se li-qui-dó mas de lo que yo que-ria.
% Que pa-cí-fi-co es-tá hoy el lo-co. ¡Gra-cias á Dios que se pa-ci-fi-có! cuan-do es-toi en ca-sa yo lo pa-ci-fi-co.
Que pa-cí-fi-co es-tá hoy el pe-rro. ¡Gra-cias á Dios que se pa-ci-fi-có! cuan-do es-toi en ca-sa yo lo pa-ci-fi-co.
Es-te ni-ño me in-co-mo-dó a-yer de u-na ma-ne-ra ter-ri-ble, sa-be que me in-co-mo-do fá-cil-men-te, y pa-re-ce que se pro-po-ne ha-cer-se lo mas in-có-mo-do y ma-ja-de-ro po-si-ble.
# ---- From here on the book has "lecturas corridas": normal text, no syllable breaks. ----
= LECTURA 14 (pages 63-71) El niño bueno y el niño malo (The good boy and the bad boy)
@pic 🤝
@ The good boy and the bad boy: kind Dieguito helps a poor boy, a carpenter's son, while proud Lorenzo is ashamed to be seen with him. Dieguito takes the boy home, and his parents give him clothes and food.
> (*) El maestro no debe precipitar al niño en la lectura, cuando cometa una falta le hará repetir la palabra varias veces, y si se le hiciese muy difícil, lo llevará al tema y egercicio donde se encuentren sus componentes para que recuerde el modo de silabearla. Se recomienda el esmero en hacer pronunciar bien al niño.
# Teacher note meaning: don't rush; on a mistake have the child repeat the word, and if it is hard go back to the lesson with those syllables.
Una hermosa tarde de verano paseaban juntos dos niños ricamente vestidos, y al salir de una tienda donde habian estado comprando infinidad de juguetes, encontraron á un pobrecito sucio y andrajoso que les pidió una limosna en nombre de Dios: uno de ellos llamado Dieguito, que tenia muy buen corazon se detuvo á hablar con el desgraciado niño y darle algun socorro; pero Lorenzo que era muy soberbio y orgulloso le dijo á su compañero con aire insolente y despreciativo: dime Dieguito ¿No te avergüenzas de pararte en la calle á hablar con ese muchacho tan sucio y andrajoso? Vámonos que pueden vernos los que pasan y creer que somos pobres como él.
—¡Cómo! replicó Dieguito lleno de indignacion. ¿Por qué he de avergonzarme de que me vean hablando con un niño pobre y desgraciado? ¿Acaso ser pobre deshonra á nadie? No sabes tú que Jesucristo fué mas pobre que ninguno y que predicaba la pobreza?
—Vámonos, interrumpió Lorenzo, vámonos, Dieguito, no seas tonto y al decir esto tiraba á Dieguito por el brazo; vámonos que ya reconozco á ese muchacho: su padre es un carpintero viejo que vive en un aposento muy sucio; vámonos ¡Ah! qué vergüenza si pasase algun conocido y nos viese aquí parados al lado del hijo de un carpintero...
—Cállate, necio, le dijo Dieguito lleno de dignidad; los artesanos todos son dignos de consideracion y respeto cuando son honrados. ¿Quién fué San José? ¿No fué un humilde carpintero? y sin embargo ¿No lo eligió Dios para esposo de la Vírgen María, Madre de Jesucristo? ¿Y quién es mas grande que Jesucristo en todo lo creado? y sin embargo ¿No fué su Madre la esposa de un carpintero? no nació él en un humilde pesebre siendo todo un Dios?...
Vete de mi lado Lorenzo, vete que lo que verdaderamente me causa vergüenza es haber sido tu amigo y estar todavía al lado de un niño tan orgulloso, tan necio y de tan mal corazon como tú, vete, vete y diciendo esto lo empujó con violencia de su lado.
El niño pobre sorprendido de ver tanta bondad le dijo á Dieguito con un acento lleno de tristeza y dulzura.
—¡Oh niño qué bueno sois! ¡Dios os bendiga! ¡Ah! llame U. á su amigo, no riña U. con él por culpa mia, sí, llámelo U...
—Yo sé lo que hago, le contestó Dieguito, yo no puedo ser amigo de un niño que es tan orgulloso y tiene tan mal corazon. ¿Quieres venir conmigo donde mis padres? Ven, dame la mano. ¡Cuánto se alegrarán cuando te vean! ya verás como te darán algunos vestiditos mios y otras cosas bonitas.
En efecto, los dos niños llegaron juntos á la casa de Dieguito, que suplicó á sus padres le diesen al pobrecito algunas ropitas, y despues que comió lo despidió dándole un canastito con frutas para sus hermanitos y algun dinero para su padre.
El pobrecito se despidió llorando de alegría y gratitud y pidiéndole á Dios que protegiese aquel niño tan generoso y tan caritativo.
Los padres de Dieguito lloraron de placer al ver que tenian un hijo con tan noble corazon, lo abrazaron y besaron mil veces y le hicieron infinidad de regalos, y Dieguito no quiso jamás volver á ver á Lorenzo.
= LECTURA 14b (pages 68-71, "Segunda parte")
@pic 🤝
@ Thirty years later: Diego is rich and generous. A beggar and a carpenter meet him. The carpenter is the boy he once helped, and the beggar is proud Lorenzo. Diego takes Lorenzo into his home.
Ya habian pasado mas de treinta años, Dieguito naturalmente era ya un hombre casado y como habia seguido siendo bueno con todos y en particular con los pobres, Dios lo habia protegido haciéndolo muy rico, para que tuviese mas que darle á los pobres que acudian en gran número á su casa porque sabian que siempre los socorria.
Una tarde cuando salia de paseo se le acercó un pobre muy flaco, sucio y andrajoso y le pidió una limosna en nombre de Dios, y miéntras Dieguito sacaba la bolsa para socorrerlo pasó un carpintero con sus herramientas en la mano y se detuvo tambien para darle una limosna al mendigo, al darle Dieguito su socorro al pordiosero le dijo, vaya U. siempre á mi casa vivo cerca de aquí y me llamo don Diego Sanchez.
—¡Cómo! esclamaron á un mismo tiempo el pordiosero y el carpintero ¿es U. D. Diego Sanchez?
—Sí contestó este con calma ¿por ventura me conocen Uds.?
—Yo creo conocerlo añadió el carpintero, recuerdo que un dia cuando éramos muy niños le pedí á U. una limosna á las puertas de una tienda y U. á pesar de los consejos de un niño de muy mal corazon y muy orgulloso con quien iba, no tan solo me socorrió sino que me llevó á su casa donde junto con sus padres me colmaron de favores y caricias.
—¡Ay Dios mio! esclamó el pordiosero, esta es tu mano que me castiga justamente, yo soy aquel niño malo, yo soy Lorenzo... y se cubrió la cara con las manos.
—¡Cielos! dijo Diego sorprendido ¿será posible tanta casualidad?...
—No es casualidad, contestó Lorenzo, así lo ha querido Dios para mi espiacion: yo era muy orgulloso y soberbio y debia sufrir muchas humillaciones en la vida, despreciaba la pobreza y hoy no hay nadie mas pobre que yo; me importunaban los mendigos y por castigo Dios me ha condenado á pedir una limosna de puerta en puerta, para que sufra el mismo desprecio con que recibia yo á los que me pedian...
Diego lloraba enternecido al escuchar las palabras del desgraciado Lorenzo: le dió la mano, lo llevó á su casa y en ella lo recogió como tenia á otros muchos infelices, porque Diego era muy rico, pues el Señor multiplica los bienes del que da á los pobres y le quita todas las riquezas al avaro que no los socorre y los desprecia.
= LECTURA 15 (pages 71-74) Puerto Rico and the Taíno people
@pic 🌴
@ Puerto Rico and the Taíno people: the islands nearby, how the Taíno lived, the cacique Agüeynaba, Ponce de León's arrival in 1509, forced labor, war and disease, and the Taíno heritage that lives on.
# EDITED (option 2), a bigger rewrite: the 1861 text calls the Taíno people "salvages" (savages) who went naked and
# "did not know who God was", says Ponce de León was "very humane", and ends "the Indians disappeared". The gentle
# version describes how the Taíno lived and tells the history plainly: forced labor, war and disease, and that their
# words and families live on in Puerto Rico. It also drops "Santo Domingo has just rejoined Spain" (true only in 1861).
Ya sabeis, niños, que Cristóbal Colon ausiliado por la grande y magnánima Reina Isabel la Católica descubrió el Nuevo Mundo que es la América, y que Puerto-Rico es una isla que está en esta parte del mundo.
% Ahora debeis saber que cerca de la isla de Puerto-Rico hay otras muchas islas pero que solo hay dos mayores que ella y son la isla de Cuba, cuya Capital es la Habana y que pertenece á España lo mismo que Puerto-Rico, y la de Santo Domingo cuya parte oriental acaba de reincorporarse á la nacion española.
Ahora debeis saber que cerca de la isla de Puerto-Rico hay otras muchas islas pero que solo hay dos mayores que ella y son la isla de Cuba, cuya Capital es la Habana, y la de Santo Domingo.
% Antes de venir los españoles á Puerto-Rico vivian en él unos hombres indios, de color de cobre, que andaban desnudos por los bosques, y no sabian quien era Dios ni ménos entendian de leer ni escribir, ni tenian máquinas ningunas, y por lo tanto eran unos salvages. Tenian una especie de Rey al que llamaban Cacique, y el nombre del que gobernaba cuando vino por primera vez Juan Ponce de Leon era Agüeynaba. Este Cacique Agüeynaba recibió á los españoles con mucha afabilidad y buena fé.
Antes de venir los españoles á Puerto-Rico vivian en él los indios taínos. Vivian en pueblos, sembraban yuca y maíz, pescaban en el mar y en los rios, y hacian canoas, hamacas y vasijas de barro. Tenian una especie de Rey al que llamaban Cacique, y el nombre del que gobernaba cuando vino por primera vez Juan Ponce de Leon era Agüeynaba. Este Cacique Agüeynaba recibió á los españoles con mucha afabilidad y buena fé.
% El Capitan que venia mandando la gente que vino á poblar esta Isla se llamaba Juan Ponce de Leon, el cual llegó á ella el año de mil quinientos nueve, y fué un hombre muy humano y muy bueno con los pobres indios, que por su parte no molestaban á los pocos españoles que habia en la isla, por el contrario los ayudaban en todo lo que podian; pero murió el Cacique Agüeynaba que era muy bueno, y heredó el Cacicazgo ó sea el gobierno de los indios un hermano suyo muy temerario é indómito y el cual empezó á perseguir á los españoles hasta que les declaró la guerra: entónces Ponce de Leon tuvo que defenderse y en una batalla cerca de la Capital mató á Agüeynaba segundo y se acabó la guerra poco tiempo despues; desde entónces empezó á aumentarse el número de Españoles y á disminuirse el de los indios hasta que desaparecieron todos.
El Capitan que venia mandando la gente que vino á poblar esta Isla se llamaba Juan Ponce de Leon, el cual llegó á ella el año de mil quinientos nueve. Los indios ayudaban á los españoles en todo lo que podian; pero los españoles los obligaron á trabajar para ellos. Murió el Cacique Agüeynaba que era muy bueno, y heredó el Cacicazgo ó sea el gobierno de los indios un hermano suyo, que luchó por la libertad de su pueblo y declaró la guerra á los españoles. En una batalla cerca de la Capital murió Agüeynaba segundo, y se acabó la guerra poco tiempo despues. Con la guerra, el trabajo forzado y las enfermedades que vinieron de Europa, el número de los indios se hizo muy pequeño; pero muchas familias de Puerto-Rico tienen abuelos taínos, y todavía decimos palabras taínas como hamaca, canoa y huracán.
= LECTURA 16 (pages 74-76) The island of Puerto Rico (geography, as it was in 1861)
@pic 🗺️
@ The island of Puerto Rico as it was in 1861: its size, mountains (El Yunque), rivers, crops (sugar cane, coffee, tobacco), animals, salt flats, and 500,000 people in 69 towns.
La isla de Puerto-Rico á quien los indios naturales llamaban Boriquen, está en el mar Atlántico, y como es isla, el mar la rodea por todas partes.
Tiene treinta leguas de largo y catorce de ancho; sus tierras son muy buenas para sembrar en ellas, hay muchas colinas y unas montañas muy grandes que dividen la isla en dos partes de oriente á poniente; y el punto mas alto de esas montañas se llama el Yunque de Luquillo.
El clima de la isla es bastante caluroso pero no hay en ella muchas enfermedades. La isla tiene mas de cincuenta rios grandes y mas de mil doscientas quebradas, y esa gran abundancia de aguas hace que las tierras produzcan mucho fruto; tambien hay muchos montes que tienen maderas muy buenas para fabricar casas y hacer muebles.
Los labradores del país que son los que cultivan la tierra, siembran mucha caña para hacer azúcar que es lo que produce mas dinero, tambien siembran café, tabaco y un poco de algodon, ñames, plátanos, batatas, arroz y otras cosas.
Hay en Puerto-Rico muchas aves silvestres, muchas reses y muchos peces con lo cual se mantiene la gente fácilmente, y en algunos rios se encuentran pedacitos de oro, como en el de Loiza.
Tambien hay salinas en donde se forma la sal, como en las de Coamo, Cabo-rojo y Guánica.
# EDITED (option 2): "contando blancos, mulatos y negros" (counting whites, mulattoes and blacks) -> left out.
% El número de habitantes que tiene la isla contando blancos, mulatos y negros es quinientos mil, repartidos en los sesenta y nueve pueblos que hay en ella, inclusa la Capital y la isla de Vieques, que está frente de Fajardo.
El número de habitantes que tenia la isla era quinientos mil, repartidos en los sesenta y nueve pueblos que habia en ella, inclusa la Capital y la isla de Vieques, que está frente de Fajardo.
= LECTURA 17 (pages 76-79) Juan Ponce de León, conquistador de Puerto-Rico
@pic 🧭
@ Juan Ponce de León: from Spain to Santo Domingo, first Spanish governor of Puerto Rico, the search for the Fountain of Youth, Florida, his death in Cuba, and his tomb in San Juan.
# EDITED (option 2): the 1861 text praises the conquest ("killed the Cacique personally", "punish the Carib Indians
# who came to steal the Indians to eat them" - an old false story used to justify enslaving the Caribs). The gentle
# version keeps the facts of his life (Spain, Santo Domingo, the Fountain of Youth, Florida, his death in Cuba).
% Nada mas justo que recordar el nombre del conquistador de nuestro país, la isla de San Juan Bautista de Puerto-Rico.
Esta es la historia de Juan Ponce de Leon, el primer gobernador español de la isla de San Juan Bautista de Puerto-Rico.
% Como ya hemos dicho el conquistador se llamaba Juan Ponce de Leon y era natural de Lun en España, donde ganó fama de valiente guerreando contra los moros, despues vino á América con los primeros españoles que vinieron á poblar á Santo Domingo, esa isla que está cerca de la de Puerto-Rico.
Como ya hemos dicho el conquistador se llamaba Juan Ponce de Leon y era natural de España, despues vino á América con los primeros españoles que vinieron á poblar á Santo Domingo, esa isla que está cerca de la de Puerto-Rico.
% En Santo Domingo donde los indios no eran tan humildes como en Puerto-Rico, peleó mucho tiempo contra ellos y siempre los venció, y así que se tranquilizaron, como no le gustaba la vida quieta pidió venir á Puerto-Rico de conquistador para gobernarla despues, y se le concedió el año mil quinientos nueve y tambien peleó aquí contra los indios, cuando los insubordinó el Cacique Agüeynaba á quien mató personalmente.
Como no le gustaba la vida quieta pidió venir á Puerto-Rico para gobernarla, y se le concedió el año mil quinientos nueve.
El génio de Ponce de Leon no se acostumbraba á estar en la ociosidad y habiendo oido decir que existia un pais donde habia una fuente llamada de "Bimini" cuyas aguas tenian la virtud de rejuvenecer á los que se bañaban en ellas, tanto por que le gustaban las aventuras, cuanto porque deseaba fortalecerse él mismo que estaba ya cansado y viejo, reunió unos cuantos hombres y salió en busca de dicha fuente, y descubrió por casualidad la Florida, pero nunca las aguas milagrosas.
% Ponce se embarcó en seguida para España á dar cuenta al Rey Fernando del descubrimiento que habia hecho; el Rey lo recibió con mucha afabilidad y lo nombró Adelantado de Bimini y la Florida, dándole el mando de una expedicion para castigar á los indios caribes que á menudo venian á Puerto-Rico á robarse á los indios para comérselos.
Ponce se embarcó en seguida para España á dar cuenta al Rey Fernando del descubrimiento que habia hecho; el Rey lo recibió con mucha afabilidad y lo nombró Adelantado de Bimini y la Florida.
% Descansó Ponce algunos años en esta isla y vuelto á encenderse en su corazon el deseo de las aventuras, armó otra expedicion á su costa para ir á conquistar los indios de la Florida, y salió con tres buques del puerto de San German el año de mil quinientos veinte y uno; pero aquellos indios eran tan valientes que lo recibieron á flechazos, y herido Ponce de Leon en un muslo se dirigió á la isla de Cuba donde murió, y sobre su sepulcro se colocó este epitafio que mereció por su valor y arrojo.
Descansó Ponce algunos años en esta isla y vuelto á encenderse en su corazon el deseo de las aventuras, armó otra expedicion á su costa para ir á la Florida, y salió con tres buques del puerto de San German el año de mil quinientos veinte y uno; pero los indios de la Florida defendieron su tierra, y herido Ponce de Leon en un muslo se dirigió á la isla de Cuba donde murió, y sobre su sepulcro se colocó este epitafio.
Aqueste lugar estrecho
Es sepulcro del varon
Que en el nombre fué Leon
Y mucho mas en el hecho.
% Mas tarde se trasladaron sus cenizas á esta isla que debia guardarlas por haber sido él quien la conquistó y ademas porque fué su primer gobernador.
Mas tarde se trasladaron sus cenizas á esta isla porque fué su primer gobernador.
Sus restos están depositados al lado izquierdo del altar mayor del Convento de Reverendos Padres Domínicos bajo una loza que dice así:
"Aquí yace el muy ilustre señor Juan Ponce de Leon, primer Adelantado de la Florida, primer conquistador y gobernador de esta isla de San Juan. Este enterramiento y capilla es de sus herederos, y patronazgo della, de Juan Ponce de Leon su nieto y de sus hijos de Doña Isabel de Loayza."
= LECTURA 18 (pages 81-86) El Padre Gimenez (Father Giménez, a priest who gave everything to the poor)
@pic ⛪
@ Father Giménez, a priest from Cabo Rojo who gave everything to the poor, taught orphans, and fasted 40 days so he would not be promoted. All of Puerto Rico mourned him in 1855.
# EDITED (option 2): "las impertinencias de los dementes" (the impertinence of the insane) -> "las quejas de los enfermos"
# (the complaints of the sick).
Los hombres virtuosos honran tanto á su patria como los sabios y los grandes conquistadores, y por esta razon Puerto-Rico debe recordar siempre para venerar su memoria, al virtuoso Sacerdote llamado humildemente Padre Gimenez.
El Doctor Don Juan Francisco Gimenez nació en un pueblo de esta isla llamado Cabo-rojo, el año de mil setecientos ochenta y tres, y guiado por su corazon á la carrera eclesiástica, vino muy jóven á la Capital donde hizo sus primeros estudios y se ordenó de Sacerdote.
Mas tarde pasó á la vecina isla de Santo Domingo, en cuya Universidad recibió los grados de Bachiller en Filosofía y Doctor en sagrada Teología, y al ver allí sus talentos y virtudes le ofrecieron una Canongía envidiada de muchos; pero que su humildad no quiso aceptar.
Solo tenia treinta años el virtuoso Padre Gimenez, cuando ya se le habian conferido cargos muy honrosos y delicados y que solo aceptaba por obediencia á sus superiores, así fué nombrado Fiscal eclesiástico y mas tarde miembro de una comision régia que vino á estudiar las necesidades del país para proponer al Rey las mejoras que creyese conveniente. Desde que se ordenó fué Director Espiritual de las Madres Monjas Carmelitas, y Catedrático de latinidad y filosofía, y su desprendimiento era tal, que dejaba una considerable parte de su sueldo á favor de los fondos del Seminario.
Vacante una vez en mil ochocientos cuarenta y cinco el gobierno del Obispado, el Cabildo eclesiástico lo nombró por unanimidad Provisor de esta diócesis, y solo aceptó el cargo despues de muchas súplicas, y cuando se le hizo comprender el deber en que estaba de admitir el cargo en servicio de Dios, y de su Iglesia.
En este delicado destino manifestó el Padre Gimenez todo su saber, toda su caridad y toda la energía de su alma, fuerte por la virtud.
% Electo Director de la casa de Beneficencia, el pueblo gozó viéndolo en el terreno de su vocacion, dejando todo su sueldo en favor de la casa, enseñando á los desgraciados huérfanos con el amor de un padre cariñoso, consolando á los pobres y ancianos con sus santas palabras, y sufriendo con una piadosa tolerancia las impertinencias de los dementes.
Electo Director de la casa de Beneficencia, el pueblo gozó viéndolo en el terreno de su vocacion, dejando todo su sueldo en favor de la casa, enseñando á los desgraciados huérfanos con el amor de un padre cariñoso, consolando á los pobres y ancianos con sus santas palabras, y sufriendo con una piadosa tolerancia las quejas de los enfermos.
El Padre Gimenez jamás tuvo dinero, tan pronto como lo recibia lo repartia á los pobres, y su humilde trage de Sacerdote cubria muy amenudo sus desnudas espaldas, por que nunca tuvo mas ropa que la escasamente necesaria, y aun esa misma la daba al primer anciano ó pobre enfermo que se le presentaba mal vestido.
Sus ratos desocupados, que eran muy pocos, los dedicaba al consuelo de los enfermos, presos, y afligidos y aun en el arreglo de disensiones familiares, porque siempre acudian á él para todo. Sus palabras eran un oráculo para el pueblo que tenia en el toda su fé y confianza: bastaba decir, el Padre Gimenez lo ha dicho, para que se creyesen ó aceptasen las cosas mas dudosas.
Las Autoridades de Puerto-Rico habian suplicado á la Reina que elevase á mayor rango tan digno Sacerdote: el Padre Gimenez lo supo, é hizo la promesa de ayunar cuarenta dias para que Su Magestad desatendiese las grandes recomendaciones que á su favor se habian hecho.
El tres de Abril de mil ochocientos cincuenta y cinco acabó de cumplir su promesa; encontrábase el Padre Gimenez en el mejor estado de salud, y al toque de oraciones, cuando salia de rezar del Convento de Santo Domingo, cayó de rodillas en la calle y espiró como un santo, con los ojos fijos en el Cielo y sin pronunciar un lamento.
Cubrióse de luto Puerto-Rico al saber su desgracia con la pérdida del virtuoso Sacerdote, y jamás hombre alguno ha bajado al sepulcro acompañado de tantas lágrimas y bendiciones.
Un numeroso gentío seguia al féretro que encerraba los restos del Padre Gimenez, y desde las primeras Autoridades hasta el mas humilde pordiosero, iban regando con sus lágrimas el fúnebre camino por donde se conducia á su última morada á tan virtuoso sacerdote: preciosísimas flores que arrojaba el corazon agradecido sobre la tumba de su mejor amigo.
= LECTURA 19 (pages 87-90) El Padre Rufo (Father Rufo, the teacher who brought the first science lab to Puerto Rico)
@pic 🔬
@ Father Rufo, a priest who taught for free, bought Puerto Rico's first physics and chemistry lab with his own money, and helped young Puerto Ricans study in Europe.
Con este título humilde llamaban sus discípulos al Doctor Don Rufo Manuel Fernandez; un virtuoso sacerdote nacido en Galicia y que vino á Puerto-Rico el año de mil ochocientos treinta y siete con el destino de Racionero del Cabildo Eclesiástico, mas tarde ascendió á otros puestos de mayor importancia, y por último alcanzó la dignidad de Arcediano.
Dedicado desde su juventud á la enseñanza, el Doctor Rufo sentia un gran placer en trasmitir á los demas sus vastos conocimientos, y así no tardó mucho en ocuparse de enseñar la juventud puerto-riqueña sin retribucion alguna, por el contrario, á su costa compró un gabinete de Física y Química, el primero y único hasta ahora que ha venido á Puerto-Rico: alquiló una casa con ese solo objeto, costeaba todos los productos químicos que eran necesarios para los experimentos, y daba públicamente sus clases.
La mayor recomendacion que un jóven podia alcanzar para con tan digno Sacerdote era el ser estudioso, y así se tomó mucho interés y contribuyó en gran manera al proyecto de llevar á Europa algunos jóvenes puerto-riqueños, para que se instruyesen en varias ciencias que despues debian venir á esplicar á la juventud del país.
Realizóse este proyecto, y la asiduidad del Padre Rufo fué tanta, que él mismo los acompañó á la Madre patria, para gozarse al verlos ya afiliados en las diversas Cátedras á que los dedicó.
El país debe conservar y venerar siempre la memoria del virtuoso Padre Rufo, por que ademas de haber sido un Sacerdote de grandes virtudes, le cupo la gloria de ser el primer Catedrático que hubo en el de Física y Química; fué él quien á su costa fundó el primer laboratorio de dichas ciencias, y el cual regaló mas tarde á la Sociedad Económica de Amigos del País.
El Padre Rufo merece el título de el mejor amigo de la juventud puerto-riqueña, porque ninguno se interesaba mas que él en su ilustracion, ni propendió mas que él por todos los medios posibles á conseguir ese noble objeto, á pesar de la murmuracion y los tiros de sus gratuitos enemigos.
Agobiado al fin por sus padecimientos físicos y morales, murió en el vecino pueblo de Caguas el año de mil ochocientos cincuenta y cinco, rodeado de sus discípulos y amigos y lamentado por todos los que en el país conocian bastante sus méritos y servicios para sentir debidamente su muerte.
El Padre Rufo murió como mueren los hombres buenos, con la mayor tranquilidad y la conformidad mas evangélica.
Sus restos están depositados en el cementerio de Caguas.
= LECTURA 20 (pages 91-96) Don Alejandro Ramirez (the official who rebuilt Puerto Rico's economy)
@pic 📜
@ Don Alejandro Ramírez, the official who rebuilt Puerto Rico's economy after 1813: open ports, a newspaper, a bank, and the Society of Friends of the Country. He died poor.
El recuerdo de los varones ilustres que se desvelaron por labrar la felicidad de un país, (dice un ilustrado puerto-riqueño) es no solo un tributo de agradecimiento debido á su memoria, sino tambien un ejemplo provechoso para que los venideros se enciendan en el noble deseo de imitar sus acciones.
Por esta razon hemos creido oportuno recordar al ilustrado Intendente D. Alejandro Ramirez, á quien debe Puerto-Rico una gran parte, si no toda la prosperidad de que hoy disfruta.
Nació el Sr. Ramirez en la Villa de Alaejos en Castilla la Vieja, el veinte y cinco de Febrero de mil setecientos setenta y siete, y desde sus primeros años empezó á dar muestras, tanto de su claro ingenio, como de sus virtudes.
Dedicado á la carrera de la Hacienda pasó al Reino de Goatemala, en la América, á continuar sus servicios en mil setecientos noventa y cuatro, y allí pasó diez y siete años en los que cimentó su futura gloria y su fama de hombre probo, laborioso, gran rentista y literato.
Debido á las atribuciones é influencias del digno puerto-riqueño D. Ramon Power Vice-Presidente entónces de las Córtes Españolas, fué nombrado el Sr. Ramirez Intendente de Puerto-Rico, y llegó á esta Capital en mil ochocientos trece dejando un inmenso vacío y un pesar profundo entre los habitantes de Goatemala.
El Sr. Ramirez era el primer Intendente que venia á Puerto-Rico, el país se encontraba en el estado mas calamitoso, sin agricultura, sin artes, sin comercio é industrias, las arcas del Gobierno no solo vacías, sino empeñadas, y para remediar este considerable mal se habia establecido el papel moneda que produjo otros mayores: en una palabra, el país estaba arruinado y sin prestigio alguno: el Señor Ramirez debia ser su salvador.
Poco tiempo bastó á la clara inteligencia de nuestro primer Intendente para descubrir las causas de aquellas desgracias, y aplicarles un eficaz remedio; así inmediatamente abrió todos los puertos al Comercio, estableció Aduanas y Receptorías, y tomó otras medidas que produjeron considerables sumas de dinero, al año mismo de haber pisado nuestro suelo.
Incansable el Sr. Ramirez en levantar á Puerto-Rico del estado de postracion en que lo encontrara, tocó cuantos resortes tuvo en su mano para conseguir tan noble fin: con objeto de ilustrar las cuestiones que proponia, fundó el Diario Económico y seguidamente una Caja de Cambios y Amortizacion que al primer año de sus operaciones esterminó casi por completo el papel moneda: él fué el patrocinador de nuestra libertad comercial, él, quien fundó la Sociedad Económica de Amigos del País y el Consulado de Agricultura y Comercio, y por último, al despedirse de Puerto-Rico para pasar de Intendente á la Habana nos dejó el grato recuerdo de una Cédula de gracias que nos ha dado la civilizacion y el bienestar que hoy disfrutamos.
Por esto Puerto-Rico debe venerar eternamente el nombre de su bienhechor Ramirez. La Sociedad Económica de Amigos del País, con toda la solemnidad posible, colocó su retrato en la Sala de sus sesiones, y algun dia llegará en que el País entero manifieste su gratitud á tan digno é ilustrado Gefe de una manera mas pública.
En la isla de Cuba siguió siendo Ramirez el mismo hombre, y allí como en todas partes mereció por sus talentos y virtudes la admiracion y el respeto de todos.
Ramirez murió de Superintendente en Nueva España, siendo el ídolo de sus gobernados.
Secretario de la Capitanía General de Goatemala (dicen los apuntes que estractamos) Intendente de Puerto-Rico y de la Habana, y Superintendente general de Hacienda de la Nueva España, murió pobre, hasta el estremo de haber menester que la munificencia soberana le consignara una pension á su viuda, para educar y mantener ocho huérfanos menores. Este es el mejor elogio de D. Alejandro Ramirez.
# FINIS (end of the book, page 96)
EOF
modern-es.json: the spelling list: accent fixes (screen and voice), voice-only fixes, abbreviations, and Lesson 13's drill words to leave alone.
cat > /root/abcbooks-spanish/modern-es.json <<'EOF'
{
"_about": "1861 spelling -> modern spelling. 'accent' entries have the same letters (only accents change), so they are also applied to the text on screen, keeping the book's syllable hyphens. 'spoken' entries change letters and are only used for what the voice says and the speech check. Keys are lowercase without hyphens. Rules in make-book-es.ps1 also add: -cion/-sion/-xion -> -ción..., vosotros -ais/-eis -> -áis/-éis, lone á/ó/é -> a/o/e.",
"accent": {
"abria": "abría", "acudian": "acudían", "alegrarian": "alegrarían", "comia": "comía", "compadecian": "compadecían",
"conducia": "conducía", "conocian": "conocían", "cubria": "cubría", "daria": "daría", "debia": "debía", "debian": "debían",
"decia": "decía", "dia": "día", "dias": "días", "entendian": "entendían", "existia": "existía", "groseria": "grosería",
"habia": "había", "habian": "habían", "hacian": "hacían", "leia": "leía", "mia": "mía", "mias": "mías", "mio": "mío",
"mios": "míos", "moriria": "moriría", "oia": "oía", "pedian": "pedían", "podia": "podía", "podian": "podían",
"poseia": "poseía", "proponia": "proponía", "queria": "quería", "recibia": "recibía", "regian": "regían",
"repartia": "repartía", "rio": "río", "rios": "ríos", "sabian": "sabían", "salia": "salía", "seguia": "seguía",
"sentia": "sentía", "socorria": "socorría", "tenia": "tenía", "tenian": "tenían", "tia": "tía", "tio": "tío",
"venia": "venía", "venian": "venían", "vivia": "vivía", "vivian": "vivían", "vacio": "vacío", "cria": "cría",
"afan": "afán", "algodon": "algodón", "algun": "algún", "ningun": "ningún", "anglosajon": "anglosajón", "azafran": "azafrán",
"barbiquin": "barbiquín", "barbon": "barbón", "bribon": "bribón", "capitan": "capitán", "colon": "colón",
"corazon": "corazón", "dragon": "dragón", "esplin": "esplín", "german": "germán", "gloton": "glotón", "leon": "león",
"montenegron": "montenegrón", "musulman": "musulmán", "padron": "padrón", "perpiñan": "perpiñán", "quitrin": "quitrín",
"raglan": "raglán", "ramon": "ramón", "razon": "razón", "renglon": "renglón", "rincon": "rincón",
"salchichon": "salchichón", "simplon": "simplón", "sinrazon": "sinrazón", "trapalon": "trapalón", "troton": "trotón",
"varon": "varón", "jamas": "jamás", "ademas": "además", "demas": "demás", "despues": "después", "tambien": "también",
"quizas": "quizás", "jesus": "jesús", "asi": "así", "alli": "allí", "aqui": "aquí", "mama": "mamá", "papa": "papá",
"oidme": "oídme", "cristobal": "cristóbal", "estoi": "estoy", "esclamaron": "exclamaron", "esclamó": "exclamó",
"esclamaciones": "exclamaciones", "ausiliado": "auxiliado", "ausiliada": "auxiliada", "goatemala": "guatemala",
"obgeto": "objeto", "vírjen": "virgen", "sábio": "sabio", "génio": "genio", "régia": "regia", "córte": "corte",
"exíguo": "exiguo", "fué": "fue", "dió": "dio", "vió": "vio", "salvages": "salvajes", "trages": "trajes", "trage": "traje",
"personages": "personajes", "embalage": "embalaje", "encage": "encaje", "magestad": "majestad", "gefe": "jefe",
"esperiencias": "experiencias", "esplicando": "explicando", "esplicar": "explicar", "esplicará": "explicará",
"espiacion": "expiación", "esponer": "exponer", "estractamos": "extractamos", "esterminó": "exterminó",
"estremo": "extremo", "espiró": "expiró", "espirando": "expirando", "estendereis": "extenderéis", "esposito": "expósito",
"exposito": "expósito", "impolitico": "impolítico", "impudico": "impúdico", "asmatico": "asmático", "organo": "órgano", "osculo": "ósculo", "ulcera": "úlcera", "invalido": "inválido",
"omnimodo": "omnímodo", "optico": "óptico", "optimo": "óptimo", "orbita": "órbita", "ultimo": "último", "indigo": "índigo",
"aspero": "áspero", "aspera": "áspera", "comico": "cómico", "numero": "número", "facil": "fácil", "dificil": "difícil",
"atlanticos": "atlánticos", "rotulo": "rótulo", "citara": "cítara", "solido": "sólido", "cantara": "cántara",
"tremolos": "trémolos", "loiza": "loíza", "pais": "país", "oido": "oído", "oidos": "oídos", "entónces": "entonces",
"fé": "fe", "creido": "creído", "leido": "leído", "reir": "reír", "rie": "ríe", "rien": "ríen", "baul": "baúl",
"todavia": "todavía", "tenias": "tenías", "habias": "habías", "decias": "decías", "venias": "venías",
"querias": "querías", "podias": "podías"
},
"skip13": ["numero", "número", "numeró", "habito", "habitó", "hábito", "citara", "cítara", "citará", "rotulo", "rótulo",
"rotuló", "solido", "solidó", "sólido", "picara", "pícara", "picará", "especifico", "especificó", "específico",
"calculo", "cálculo", "calculó", "critico", "crítico", "criticó", "circulo", "círculo", "circuló", "cantara",
"cántara", "cantará", "liquido", "líquido", "liquidó", "pacifico", "pacífico", "pacificó", "incomodo", "incómodo",
"incomodó"],
"spoken": {
"puertorico": "Puerto-Rico", "puertoriqueño": "puertorriqueño", "puertoriqueña": "puertorriqueña",
"puertoriqueños": "puertorriqueños", "caborojo": "Cabo-Rojo", "ysac": "Isaac", "egercicio": "ejercicio",
"ondura": "hondura", "ectico": "héctico", "erpetico": "herpético"
},
"abbreviations": { "U.": "usted", "Uds.": "ustedes", "D.": "don", "Sr.": "señor", "&c.": "etcétera" },
"_notes": [
"Not touched on purpose: numero/numeró/rotulo/citara/solido/cantara inside LECCION 13 (the accent drill) - those words are about the accent itself, so the converter skips the accent map for EJERCICIO 13 and LECTURA 13.",
"colon (body part) never appears; in this book Colon is always Columbus.",
"sabia: wise (la sabia Reina) vs sabía (knew) was fixed by hand in the source text."
]
}
EOF
make-book-es.ps1: the converter: lessons, modern spelling, line cutting, what the voice says for every text, tracing pages, syllables for sound-it-out.
cat > /root/abcbooks-spanish/make-book-es.ps1 <<'EOF'
# Builds public/books/silabario1861.json from the hand-transcribed Silabario (spanish/source/silabario1861/silabario1861.txt).
# Usage: powershell -ExecutionPolicy Bypass -File ABCBooks\spanish\make-book-es.ps1
# (This file is saved as UTF-8 WITH a byte-order mark so Windows PowerShell reads its Spanish letters correctly.)
#
# Source format (see the top of silabario1861.txt): "= KIND n (pages) title" starts a part; "% text" is the original
# 1861 wording of the next paragraph; "@pic X" / "@ text" = picture + English meaning; "> text" = teacher note; "# ..." = notes.
#
# Each lesson in the JSON has what the app shows (words/letters/lines, keeping the book's syllable hyphens) and a
# "say" map: text the app asks for -> what the Spanish voice says (modern spelling, no syllable hyphens). Joined clips
# use "|" between pieces; a piece starting "en:" is said by the English voice ("en:Find the word,|casa.").
param([string]$Out)
$here = Split-Path -Parent $MyInvocation.MyCommand.Path
$root = Split-Path -Parent $here
if (-not $Out) { $Out = "$root\public\books\silabario1861.json" }
$src = [IO.File]::ReadAllLines("$here\source\silabario1861\silabario1861.txt", [Text.Encoding]::UTF8)
$mod = Get-Content "$here\modern-es.json" -Raw -Encoding UTF8 | ConvertFrom-Json
$ACC = @{}; foreach ($p in $mod.accent.PSObject.Properties) { $ACC[$p.Name] = $p.Value }
$SPK = @{}; foreach ($p in $mod.spoken.PSObject.Properties) { $SPK[$p.Name] = $p.Value }
$ABR = @{}; foreach ($p in $mod.abbreviations.PSObject.Properties) { $ABR[$p.Name] = $p.Value }
$SKIP13 = New-Object 'System.Collections.Generic.HashSet[string]'; foreach ($w in $mod.skip13) { [void]$SKIP13.Add($w) } # Lesson 13 is about the accent itself: leave its drill words alone
$NOSKIP = New-Object 'System.Collections.Generic.HashSet[string]'; $SKIPNOW = $NOSKIP
# ---------------------------------------------------------------- parse the source into parts
$parts = New-Object System.Collections.Generic.List[object]
$cur = $null; $orig = $null
foreach ($raw in $src) {
$l = $raw.TrimEnd()
if ($l -match '^= (\S+)(?:\s+(\d+b?))?\s*(?:\((pages?[^)]*)\))?\s*(.*)$') {
$cur = [ordered]@{ kind = $Matches[1]; num = $Matches[2]; title = $Matches[4].Trim(); pic = ''; en = ''; notes = @(); paras = New-Object System.Collections.Generic.List[object] }
$parts.Add($cur); $orig = $null; continue
}
if (-not $cur -or -not $l.Trim() -or $l.StartsWith('#')) { continue }
if ($l.StartsWith('@pic ')) { $cur.pic = $l.Substring(5).Trim(); continue }
if ($l.StartsWith('@ ')) { $cur.en = $l.Substring(2).Trim(); continue }
if ($l.StartsWith('> ')) { $cur.notes += $l.Substring(2).Trim(); continue }
if ($l.StartsWith('% ')) { $orig = $l.Substring(2).Trim(); continue }
$cur.paras.Add([ordered]@{ text = $l.Trim(); orig = $orig }); $orig = $null
}
"parts: $($parts.Count)"
# ---------------------------------------------------------------- spelling
$aE = [char]0xE1; $eE = [char]0xE9; $oE = [char]0xF3
$tokRx = '^([^\p{L}]*)(\p{L}(?:[\p{L}\-]*\p{L})?)([^\p{L}]*)$'
function KeyOf([string]$core) { ($core -replace '-', '').ToLowerInvariant() }
function MapChars([string]$core, [string]$to) { # same letters count: copy the new letters over, keep case + hyphens
$sb = New-Object Text.StringBuilder; $k = 0
foreach ($c in $core.ToCharArray()) {
if ($c -eq '-') { [void]$sb.Append('-'); continue }
$n = $to[$k++]; if ([char]::IsUpper($c)) { $n = [char]::ToUpperInvariant($n) }; [void]$sb.Append($n)
}
$sb.ToString()
}
function SetAccent([string]$core, [int]$letterIndex) { # put an acute accent on the letterIndex-th letter (hyphens skipped)
$acc = @{ [char]'a' = $aE; [char]'e' = $eE; [char]'o' = $oE; [char]'i' = [char]0xED; [char]'u' = [char]0xFA }
$chars = $core.ToCharArray(); $k = 0
for ($i = 0; $i -lt $chars.Length; $i++) {
if ($chars[$i] -eq '-') { continue }
if ($k -eq $letterIndex) { $lc = [char]::ToLowerInvariant($chars[$i]); if ($acc.ContainsKey($lc)) { $n = $acc[$lc]; if ([char]::IsUpper($chars[$i])) { $n = [char]::ToUpperInvariant($n) }; $chars[$i] = $n } }
$k++
}
-join $chars
}
# modern display form of one word (same letters, accents fixed); $hyph = the part shows syllable hyphens
function ModernCore([string]$core, [bool]$useMap, [bool]$hyph) {
$key = KeyOf $core
if ($key -eq [string]$aE) { return MapChars $core 'a' }
if ($key -eq [string]$oE) { return MapChars $core 'o' }
if ($key -eq [string]$eE) { return MapChars $core 'e' }
if (-not $useMap -or $SKIPNOW.Contains($key)) { return $core }
$new = $core
if ($ACC.ContainsKey($key) -and $ACC[$key].Length -eq $key.Length) { $new = MapChars $core $ACC[$key] }
elseif ($key.Length -gt 4 -and $key -cmatch '[csx]ion$') { $new = SetAccent $core ($key.Length - 2) }
elseif ($key.Length -ge 5 -and $key -cmatch '(ais|eis)$' -and $key -notmatch '[áéíóú]') { $new = SetAccent $core ($key.Length - 3) }
# syllable display: a stressed i/u before a vowel starts its own syllable (te-nía -> te-ní-a)
if ($hyph -and $new -ne $core) { $new = [regex]::Replace($new, '([^\-])([íú])([aeo]s?|an|en)$', '$1$2-$3') }
$new
}
function ModernText([string]$text, [bool]$useMap, [bool]$hyph) {
# 1861 abbreviations written out (U. = usted), so the screen, the voice and the reading check agree
foreach ($a in $ABR.Keys) { $text = [regex]::Replace($text, '(?<![\p{L}])' + [regex]::Escape($a) + '(?=[\s,;:!?]|$)', $ABR[$a]) }
(($text -split ' ') | ForEach-Object {
if ($_ -match $tokRx) { $Matches[1] + (ModernCore $Matches[2] $useMap $hyph) + $Matches[3] } else { $_ }
}) -join ' '
}
# what the voice says for one displayed token
function SpokenTok([string]$tok, [bool]$hyph) {
foreach ($a in $ABR.Keys) { if ($tok -ceq $a -or $tok.TrimStart([char]0x2014) -ceq $a) { return $ABR[$a] }; if ($tok -ceq ($a + ',')) { return $ABR[$a] + ',' } }
if ($tok -notmatch $tokRx) { return $tok }
$lead = $Matches[1]; $core = $Matches[2]; $trail = $Matches[3]
$key = KeyOf $core
if ($SPK.ContainsKey($key)) { $w = $SPK[$key]; if ([char]::IsUpper($core[0])) { $w = $w.Substring(0, 1).ToUpperInvariant() + $w.Substring(1) }; return $lead + $w + $trail }
if ($hyph) { $core = $core -replace '-', '' }
$lead + $core + $trail
}
function SpokenText([string]$text, [bool]$hyph) { (($text -split ' ') | ForEach-Object { SpokenTok $_ $hyph }) -join ' ' }
function Plain([string]$text, [bool]$hyph) { if (-not $hyph) { return $text }; ($text -split ' ' | ForEach-Object { $_ -replace '-', '' }) -join ' ' } # copy work: no syllable hyphens
# ---------------------------------------------------------------- Spanish syllables (for "sound it out" on words without hyphens)
$VOW = 'aeiouáéíóúü'
function IsV([string]$s, [int]$i) {
$c = $s[$i]
if ($VOW.IndexOf($c) -ge 0) { if ($c -eq 'u' -and $i -gt 0 -and ($s[$i - 1] -eq 'q' -or $s[$i - 1] -eq 'g') -and $i + 1 -lt $s.Length -and 'eiéí'.IndexOf($s[$i + 1]) -ge 0) { return $false }; return $true }
if ($c -eq 'y') { return ($i -eq $s.Length - 1) -or ($i + 1 -lt $s.Length -and $VOW.IndexOf($s[$i + 1]) -lt 0 -and $i -gt 0 -and $VOW.IndexOf($s[$i - 1]) -lt 0) }
$false
}
$INSEP = @('pl', 'bl', 'fl', 'cl', 'gl', 'kl', 'pr', 'br', 'fr', 'cr', 'gr', 'kr', 'dr', 'tr', 'tl')
function Syllables([string]$w) {
$s = $w.ToLowerInvariant()
# units: consonant digraphs ch/ll/rr and silent-u qu/gu stay together
$u = New-Object System.Collections.Generic.List[object]
for ($i = 0; $i -lt $s.Length; $i++) {
$two = if ($i + 1 -lt $s.Length) { $s.Substring($i, 2) } else { '' }
if ($two -in 'ch', 'll', 'rr') { $u.Add(@($two, 'C')); $i++; continue }
if (($two -eq 'qu' -or $two -eq 'gu') -and -not (IsV $s ($i + 1))) { $u.Add(@($two, 'C')); $i++; continue }
$u.Add(@([string]$s[$i], $(if (IsV $s $i) { 'V' } else { 'C' })))
}
# nuclei: vowel runs, split at hiatus (two strong vowels, or a stressed í/ú)
$syl = New-Object System.Collections.Generic.List[string]; $cur = ''; $i = 0; $n = $u.Count
$lastV = ''
while ($i -lt $n) {
if ($u[$i][1] -eq 'V') {
$v = $u[$i][0]
# hiatus: two strong vowels (a e o) or a stressed í/ú next to a vowel start a new syllable; else a diphthong
if ($lastV -and (('aeoáéó'.IndexOf($lastV) -ge 0 -and 'aeoáéó'.IndexOf($v) -ge 0) -or 'íú'.IndexOf($lastV) -ge 0 -or 'íú'.IndexOf($v) -ge 0)) { $syl.Add($cur); $cur = '' }
$cur += $v; $lastV = $v; $i++; continue
}
# consonant run
$j = $i; while ($j -lt $n -and $u[$j][1] -eq 'C') { $j++ }
$run = @(); for ($k = $i; $k -lt $j; $k++) { $run += $u[$k][0] }
if (-not $lastV) { $cur += ($run -join ''); $i = $j; continue } # word start
if ($j -ge $n) { $cur += ($run -join ''); $i = $j; continue } # word end
$c = $run.Count
$keep = if ($c -eq 1) { 0 } elseif ($c -eq 2) { if (($run -join '') -in $INSEP) { 0 } else { 1 } } elseif ($c -eq 3) { if (($run[1] + $run[2]) -in $INSEP) { 1 } else { 2 } } else { 2 }
$cur += (($run | Select-Object -First $keep) -join ''); $syl.Add($cur); $cur = (($run | Select-Object -Skip $keep) -join ''); $lastV = ''; $i = $j
}
if ($cur) { $syl.Add($cur) }
@($syl | Where-Object { $_ })
}
# ---------------------------------------------------------------- lessons
$NAMES = @{ a = 'a'; b = 'be'; c = 'ce'; ch = 'che'; d = 'de'; e = 'e'; f = 'efe'; g = 'ge'; h = 'hache'; i = 'i'; j = 'jota'; k = 'ka'; l = 'ele'; ll = 'elle'; m = 'eme'; n = 'ene'; o = 'o'; p = 'pe'; q = 'cu'; r = 'erre'; s = 'ese'; t = 'te'; u = 'u'; v = 've'; x = 'equis'; y = 'i griega'; z = 'zeta' }
$NAMES[[string][char]0xF1] = 'eñe'
$lessons = New-Object System.Collections.Generic.List[object]
$seen = New-Object 'System.Collections.Generic.HashSet[string]'
$SYL = [ordered]@{}
function AddSay($L, [string]$key, [string]$spoken) { if ($key -and -not $L.say.Contains($key)) { $L.say[$key] = $spoken } }
function NewLesson([string]$title, [string]$en, [string]$pic) {
[ordered]@{ n = $lessons.Count + 1; title = $title; en = $en; pic = $(if ($pic) { $pic } else { [char]::ConvertFromUtf32(0x1F4D6) }); picText = ''; words = @(); letters = @(); lines = @(); say = (New-Object System.Collections.Specialized.OrderedDictionary ([StringComparer]::Ordinal)) } # case matters: 'De' and 'de' are both asked for
}
function SplitLines([string]$p) {
# sentences, then long ones at a comma near the middle (kids read one line at a time)
$out = @()
foreach ($s in [regex]::Split($p, '(?<=[\.;:!\?])\s+(?=[\p{L}¿¡—"])')) {
$s = $s.Trim(); if (-not $s) { continue }
$todo = @($s)
while ($todo.Count) {
$t = $todo[0]; $todo = @($todo | Select-Object -Skip 1)
if ($t.Length -le 70) { $out += $t; continue }
# cut near the middle: after a comma or semicolon if there is one, else before "y", "que", "pero", "donde", "para", "de"
$mid = [int]($t.Length / 2); $best = -1; $keep = 1
foreach ($m in [regex]::Matches($t, '[,;] ')) { if ($m.Index -gt 20 -and $t.Length - $m.Index -gt 20 -and ($best -lt 0 -or [Math]::Abs($m.Index - $mid) -lt [Math]::Abs($best - $mid))) { $best = $m.Index } }
if ($best -lt 0) { $keep = 0; foreach ($m in [regex]::Matches($t, ' (?=(y|que|pero|donde|para|cuando|como|de) )')) { if ($m.Index -gt 20 -and $t.Length - $m.Index -gt 20 -and ($best -lt 0 -or [Math]::Abs($m.Index - $mid) -lt [Math]::Abs($best - $mid))) { $best = $m.Index } } }
if ($best -lt 0) { $out += $t; continue }
$todo = @($t.Substring(0, $best + $keep).Trim(), $t.Substring($best + $keep).Trim()) + $todo
}
}
$out
}
function CardWord([string]$tok) { if ($tok -match $tokRx) { $Matches[2] } else { '' } }
# record what the app will ask for in a lesson: lines, single words, cards, copy pages, board
function Finish($L, [bool]$hyph, [string]$mode) { # mode: syl (syllable/letter rows), ej (word drill), lec (reading)
foreach ($ln in @($L.lines) + @($L.linesOrig | Where-Object { $_ })) {
if ($mode -eq 'syl') { AddSay $L $ln ((($ln -split ' ') | ForEach-Object { (SpokenTok $_ $hyph) + '.' }) -join '|') }
else { AddSay $L $ln (SpokenText $ln $hyph) }
foreach ($tok in $ln -split ' ') {
$c = CardWord $tok; if (-not $c) { continue }
$sp = SpokenTok $c $hyph
AddSay $L $c ($sp + '.'); AddSay $L $c.ToLowerInvariant() ($sp + '.')
$k = ($c -replace '-', '').ToLowerInvariant()
if (-not $SYL.Contains($k)) { $SYL[$k] = @($(if ($c.Contains('-')) { $c.ToLowerInvariant() -split '-' } else { Syllables $k })) }
}
}
foreach ($w in $L.words) { AddSay $L $w ((SpokenTok $w $hyph) + '.'); AddSay $L "Find $w" ('en:Find the word,|' + (SpokenTok $w $hyph) + '.') }
foreach ($t in $L.letters) {
$nm = if ($NAMES.ContainsKey($t.ToLowerInvariant()) -and $L.alpha) { $NAMES[$t.ToLowerInvariant()] } else { $t.ToLowerInvariant() }
AddSay $L "letter:$t" "$nm."
}
# copy work (app.js uses L.copy / L.board for this book), without syllable hyphens.
# Syllable charts: up to 3 lowercase rows. Word drills: the word rows. Readings: new words, then short sentences.
if ($mode -ne 'lec') {
$rows = @($L.lines | ForEach-Object { Plain $_ $hyph })
if ($mode -eq 'syl') { $low = @($rows | Where-Object { $_ -cmatch '^\p{Ll}' }); if ($low.Count) { $rows = $low } }
$L.copy = @($rows | Select-Object -First 3); $L.board = @($rows | Select-Object -First $(if ($mode -eq 'syl') { 3 } else { 2 }))
foreach ($c in $L.copy + $L.board) { AddSay $L $c ($(if ($mode -eq 'syl') { (($c -split ' ') | ForEach-Object { (SpokenTok $_ $false) + '.' }) -join '|' } else { SpokenText $c $false })) }
foreach ($b in $L.board) { AddSay $L "Write: $b" ('en:Write.|' + $(if ($mode -eq 'syl') { (($b -split ' ') | ForEach-Object { (SpokenTok $_ $false) + '.' }) -join '|' } else { SpokenText $b $false })) }
$lessons.Add($L); return
}
$copy = @(); if ($L.letters.Count) { $copy += ($L.letters -join ' ') }
$wl = @($L.words | ForEach-Object { $_ -replace '-', '' })
for ($i = 0; $i -lt $wl.Count; $i += 6) { $copy += ($wl[$i..([Math]::Min($i + 5, $wl.Count - 1))] -join ' ') }
$sent = @(); $dup = @{}
foreach ($ln in $L.lines) { $p = Plain $ln $hyph; if (($p -split ' ').Count -gt 2 -and -not $dup.ContainsKey($p.ToLowerInvariant())) { $dup[$p.ToLowerInvariant()] = 1; $sent += $p } }
$short = @($sent | Where-Object { $_.Length -le 70 }); $sent = @($(if ($short.Count) { $short } else { $sent | Select-Object -First 1 }))
$copy += @($sent | Select-Object -First 6)
$L.copy = @($copy | Where-Object { $_ }); $L.board = @($sent | Select-Object -First 3)
if (-not $L.board.Count -and $L.lines.Count) { $L.board = @(Plain $L.lines[0] $hyph) }
foreach ($c in $L.copy) {
if ($c -eq ($L.letters -join ' ') -and $L.letters.Count) { AddSay $L $c ((($L.letters | ForEach-Object { $L.say["letter:$_"] }) -join '|')) }
else { AddSay $L $c (SpokenText $c $false) }
}
foreach ($b in $L.board) { AddSay $L "Write: $b" ('en:Write.|' + (SpokenText $b $false)) }
$lessons.Add($L)
}
foreach ($PART in $parts) {
$kind = $PART.kind; $num = $PART.num
$hyph = $kind -ne 'LECTURA' -or ([int]($num -replace 'b', '') -le 13)
$useMap = $true; $SKIPNOW = $NOSKIP; if (($num -replace 'b', '') -eq '13') { $SKIPNOW = $SKIP13 } # (an if-expression would unroll the set to null)
if ($kind -eq 'VOCALES') {
$L = NewLesson 'Vocales' 'The five vowels: a, e, i, o, u.' 'A'; $L.alpha = $true
$L.letters = @('a', 'e', 'i', 'o', 'u'); $L.lines = @($PART.paras | ForEach-Object { $_.text })
Finish $L $true 'syl'; continue
}
if ($kind -eq 'ALFABETO') {
$low = $PART.paras[1].text -split ' '; $up = $PART.paras[0].text -split ' '
$cuts = @(0, 10, 20, $low.Count)
for ($g = 0; $g -lt 3; $g++) {
$L = NewLesson "Alfabeto ($($g + 1)/3)" ('The 1861 Spanish alphabet, letters ' + $low[$cuts[$g]] + ' to ' + $low[$cuts[$g + 1] - 1] + '. Ch, ll and ñ were letters of their own.') 'Aa'
$L.alpha = $true; $L.letters = @($low[$cuts[$g]..($cuts[$g + 1] - 1)])
$L.lines = @(($up[$cuts[$g]..($cuts[$g + 1] - 1)] -join ' '), ($low[$cuts[$g]..($cuts[$g + 1] - 1)] -join ' '))
if ($g -eq 2) { $L.lines += @($PART.paras[2].text, $PART.paras[3].text) }
foreach ($ln in $L.lines) { $L.say[$ln] = ((($ln -split ' ') | ForEach-Object { $NAMES[$_.ToLowerInvariant()] + '.' }) -join '|') }
Finish $L $true 'syl'
}
continue
}
if ($kind -eq 'TEMA') {
$rows = @(); foreach ($p in $PART.paras) { $t = $p.text -split ' '; if ($t.Count -gt 15) { $h = [int][Math]::Ceiling($t.Count / 2); $rows += , @($t[0..($h - 1)]); $rows += , @($t[$h..($t.Count - 1)]) } else { $rows += , $t } }
$groups = @(); $g = @(); $uniq = @{}
foreach ($r in $rows) {
$add = @($r | ForEach-Object { $_.ToLowerInvariant() } | Where-Object { -not $uniq.ContainsKey($_) } | Select-Object -Unique)
if ($g.Count -and ($uniq.Count + $add.Count) -gt 25) { $groups += , $g; $g = @(); $uniq = @{}; $add = @($r | ForEach-Object { $_.ToLowerInvariant() } | Select-Object -Unique) }
$g += , $r; foreach ($a in $add) { $uniq[$a] = 1 }
}
if ($g.Count) { $groups += , $g }
for ($k = 0; $k -lt $groups.Count; $k++) {
$L = NewLesson ("Tema $num" + $(if ($groups.Count -gt 1) { " ($($k + 1)/$($groups.Count))" } else { '' })) '' ([char]::ConvertFromUtf32(0x1F524))
$L.letters = @(); $u2 = @{}
foreach ($r in $groups[$k]) { foreach ($s in $r) { $x = $s.ToLowerInvariant(); if (-not $u2.ContainsKey($x)) { $u2[$x] = 1; $L.letters += $x } } }
$L.lines = @($groups[$k] | ForEach-Object { $_ -join ' ' })
if ($L.letters.Count -gt 15) { $step = $L.letters.Count / 15.0; $L.letters = @(0..14 | ForEach-Object { $L.letters[[int][Math]::Floor($_ * $step)] }) } # 15 cards, spread over the chart
$L.en = 'Syllables: ' + ($L.letters -join ', ') + '.' + $(if ($PART.title) { ' (' + $PART.title + ')' } else { '' })
if ($PART.notes.Count) { $L.note = $PART.notes -join ' ' }
Finish $L $true 'syl'
}
continue
}
if ($kind -eq 'EJERCICIO') {
$toks = @(); foreach ($p in $PART.paras) { foreach ($t in (ModernText $p.text $useMap $true) -split ' ') { $c = CardWord $t; if ($c) { $toks += $c } } }
$per = 18; $cnt = [int][Math]::Ceiling($toks.Count / $per)
for ($k = 0; $k -lt $cnt; $k++) {
$w = @($toks[($k * $per)..([Math]::Min(($k + 1) * $per, $toks.Count) - 1)])
$L = NewLesson ("Ejercicio $num" + $(if ($cnt -gt 1) { " ($($k + 1)/$cnt)" } else { '' })) ("Word practice: read each word one syllable at a time. Part $($k + 1) of $cnt.") 'ca-sa'
$L.pic = [char]::ConvertFromUtf32(0x1F4DD); $L.words = @($w | Select-Object -First 12)
$L.lines = @(); for ($i = 0; $i -lt $w.Count; $i += 6) { $L.lines += ($w[$i..([Math]::Min($i + 5, $w.Count - 1))] -join ' ') }
if ($PART.notes.Count) { $L.note = $PART.notes -join ' ' }
Finish $L $true 'ej'
}
continue
}
if ($kind -eq 'LECTURA') {
# every word shown in an earlier lesson counts as seen (only new words become word cards)
foreach ($L0 in $lessons) { foreach ($ln in $L0.lines) { foreach ($t in $ln -split ' ') { $cw = CardWord $t; if ($cw) { [void]$seen.Add(($cw -replace '-', '').ToLowerInvariant()) } } } }
# lessons of whole paragraphs, about 8 lines each, so the original wording maps onto the same lesson
$paras = @($PART.paras | ForEach-Object { [ordered]@{ lines = @(SplitLines (ModernText $_.text $useMap $hyph)); orig = $(if ($_.orig) { @(SplitLines (ModernText $_.orig $useMap $hyph)) } else { $null }) } })
$chunks = @(); $c = @(); $len = 0
foreach ($p in $paras) { if ($c.Count -and $len + $p.lines.Count -gt 9) { $chunks += , $c; $c = @(); $len = 0 }; $c += , $p; $len += $p.lines.Count }
if ($c.Count) { $chunks += , $c }
for ($k = 0; $k -lt $chunks.Count; $k++) {
$L = NewLesson ("Lectura $num" + $(if ($chunks.Count -gt 1) { " ($($k + 1)/$($chunks.Count))" } else { '' })) $PART.en $PART.pic
$L.lines = @($chunks[$k] | ForEach-Object { $_.lines })
if (@($chunks[$k] | Where-Object { $_.orig }).Count) { $L.linesOrig = @($chunks[$k] | ForEach-Object { if ($_.orig) { $_.orig } else { $_.lines } }) }
# new words: first time in the book, 4+ letters, up to 10
$nw = @()
foreach ($ln in $L.lines) { foreach ($t in $ln -split ' ') { $cw = CardWord $t; $k2 = ($cw -replace '-', '').ToLowerInvariant(); if ($k2.Length -ge 4 -and -not $seen.Contains($k2) -and $nw.Count -lt 10 -and -not ($nw | Where-Object { ($_ -replace '-', '').ToLowerInvariant() -eq $k2 })) { $nw += $cw } } }
$L.words = $nw
if ($PART.notes.Count) { $L.note = $PART.notes -join ' ' }
Finish $L $hyph 'lec'
foreach ($ln in $L.lines) { foreach ($t in $ln -split ' ') { $cw = CardWord $t; if ($cw) { [void]$seen.Add(($cw -replace '-', '').ToLowerInvariant()) } } }
}
}
}
$book = [ordered]@{
title = 'Silabario'; sub = 'Puerto Rico, 1861 · en español'; color = '#a8452f'; lang = 'es'; hyph = $true
source = 'Silabario de los niños puerto-riqueños, Puerto Rico, Imprenta de Acosta, 1861 (archive.org silabariodelosni00vjld, public domain), hand-transcribed; modern accents added; gentle wording by default, original 1861 wording kept in linesOrig'
syl = $SYL; lessons = $lessons
}
[IO.File]::WriteAllText($Out, ($book | ConvertTo-Json -Depth 8 -Compress), (New-Object Text.UTF8Encoding $false))
$says = ($lessons | ForEach-Object { $_.say.Count } | Measure-Object -Sum).Sum
"lessons: $($lessons.Count) say entries: $says words with syllables: $($SYL.Count) -> $Out"
EOF
make-audio-es.ps1: records every clip with Eloquence (Spanish voice, plus English pieces), joins pieces, writes the clip list.
cat > /root/abcbooks-spanish/make-audio-es.ps1 <<'EOF'
# Records the Spanish book (public/books/silabario1861.json) with the PC's Eloquence, Latin American Spanish voice,
# at speeds 60 and 75, and writes public/audio/silabario1861.json (text the app asks for -> clip id + word start times).
# Usage: powershell -ExecutionPolicy Bypass -File ABCBooks\spanish\make-audio-es.ps1 [-Book silabario1861]
# Then: powershell -ExecutionPolicy Bypass -File ABCBooks\tools\push-audio.ps1 -Book silabario1861
#
# Each lesson's "say" map (made by make-book-es.ps1) gives what the voice says for each key. "a|b|c" = pieces recorded
# one by one and joined with a short pause (syllable rows, the way the user picked: "Version A"); a piece starting
# "en:" is said by the English voice (Reed), e.g. "en:Find the word,|casa." Pieces are cached in tools/out-es-parts.
param([string]$Book = 'silabario1861', [int[]]$Speeds = @(60, 75))
$root = Split-Path -Parent (Split-Path -Parent $MyInvocation.MyCommand.Path)
# .NET's reader, not ConvertFrom-Json: keys differ only by case ('De' and 'de'), which ConvertFrom-Json rejects
Add-Type -AssemblyName System.Web.Extensions
$js = New-Object System.Web.Script.Serialization.JavaScriptSerializer; $js.MaxJsonLength = [int]::MaxValue
$data = $js.DeserializeObject([IO.File]::ReadAllText("$root\public\books\$Book.json", [Text.Encoding]::UTF8))
$ES = 131073 # eciLanguageDialect: Mexican (Latin American) Spanish
$GAP = @{ 60 = 450; 75 = 360 } # ms between joined pieces
$say = New-Object System.Collections.Specialized.OrderedDictionary ([StringComparer]::Ordinal)
function Add($k, $v) { if ($k -and -not $say.Contains($k)) { $say[$k] = $v } }
foreach ($L in $data['lessons']) {
Add "lesson:$($L['n'])" "en:Lesson $($L['n'])."
foreach ($k in $L['say'].Keys) { Add $k $L['say'][$k] }
}
foreach ($k in $data['syl'].Keys) { foreach ($s in $data['syl'][$k]) { Add "syl:$s" "$s." } }
# The app's instructions in Spanish (public/instructions-es.json), said after the English one: key 'es:<English text>'
$ins = $js.DeserializeObject([IO.File]::ReadAllText("$root\public\instructions-es.json", [Text.Encoding]::UTF8))
foreach ($k in $ins['text'].Keys) {
$v = $ins['text'][$k]
if ($k -match '\{kid\}') { foreach ($kid in 'Layla', 'Lavon', 'Lilly') { Add ('es:' + ($k -replace '\{kid\}', $kid)) ($v -replace '\{kid\}', $kid) } }
elseif ($k -notmatch '\{') { Add "es:$k" ($v -replace [char]0x2B50, '').Trim() } # hints with numbers are only shown, not said
}
foreach ($L in $data['lessons']) {
Add "es:lesson:$($L['n'])" ($ins['spoken']['lesson:{n}'] -replace '\{n\}', $L['n'])
foreach ($w in $L['words']) { Add "es:Find $w" ($ins['spoken']['Find {w}'] -replace '\{w\}', $L['say'][$w]) }
foreach ($b in $L['board']) { $sp = ($L['say']["Write: $b"] -split '\|', 2)[1]; Add "es:Write: $b" ($ins['spoken']['Write: {t}'] -replace '\{t\}', $sp) }
}
"$($say.Count) clips per speed"
$sha = [Security.Cryptography.SHA1]::Create()
function Id($s) { (($sha.ComputeHash([Text.Encoding]::UTF8.GetBytes($s)) | Select-Object -First 6 | ForEach-Object { $_.ToString('x2') }) -join '') }
function WriteWav($path, [byte[]]$pcm, [int]$sr) {
$w = New-Object IO.BinaryWriter([IO.File]::Create($path)); $n = $pcm.Length
$w.Write([char[]]'RIFF'); $w.Write([int](36 + $n)); $w.Write([char[]]'WAVEfmt '); $w.Write([int]16); $w.Write([int16]1); $w.Write([int16]1)
$w.Write([int]$sr); $w.Write([int]($sr * 2)); $w.Write([int16]2); $w.Write([int16]16); $w.Write([char[]]'data'); $w.Write([int]$n); $w.Write($pcm); $w.Close()
}
$ps32 = 'C:\Windows\SysWOW64\WindowsPowerShell\v1.0\powershell.exe'
$items = New-Object System.Collections.Specialized.OrderedDictionary ([StringComparer]::Ordinal)
foreach ($sp in $Speeds) {
$dir = "$root\tools\out\$Book\s$sp"; $pdir = "$root\tools\out-es-parts\s$sp"
New-Item -ItemType Directory -Force $dir, $pdir | Out-Null
# 1. every distinct piece, per voice
$pieces = New-Object 'System.Collections.Generic.Dictionary[string,string]' ([StringComparer]::Ordinal) # "lang|text" -> piece id
foreach ($k in $say.Keys) { foreach ($seg in ($say[$k] -split '\|')) { $lang = if ($seg.StartsWith('en:')) { 'en' } else { 'es' }; $t = if ($lang -eq 'en') { $seg.Substring(3) } else { $seg }; $pieces["$lang|$t"] = Id "$lang|$t" } }
foreach ($lang in 'es', 'en') {
$jobs = @(foreach ($pk in $pieces.Keys) { if ($pk.StartsWith("$lang|") -and -not (Test-Path "$pdir\$($pieces[$pk]).wav.json")) { [ordered]@{ text = $pk.Substring(3); out = "$pdir\$($pieces[$pk]).wav" } } })
"speed $sp $lang : $($jobs.Count) pieces to record"
for ($i = 0; $i -lt $jobs.Count; $i += 2000) { # in batches, so a crash keeps what is done
$jf = "$pdir\jobs-$lang.json"
[IO.File]::WriteAllText($jf, (ConvertTo-Json @($jobs[$i..([Math]::Min($i + 1999, $jobs.Count - 1))])), (New-Object Text.UTF8Encoding $false))
& $ps32 -NoProfile -ExecutionPolicy Bypass -File "$root\tools\eloq-render-es.ps1" -Jobs $jf -Speed $sp -Lang $(if ($lang -eq 'es') { $ES } else { 0 }) | Out-Null
}
}
# 2. each clip = its pieces joined; word times = each piece's word starts, shifted
$made = 0
foreach ($k in $say.Keys) {
$spec = $say[$k]; $id = Id $spec
if (-not $items.Contains($k)) { $items[$k] = [ordered]@{ id = $id } }
$segs = $spec -split '\|'
if (Test-Path "$dir\$id.wav.json") { $items[$k]["t$sp"] = @((Get-Content "$dir\$id.wav.json" -Raw | ConvertFrom-Json).words); continue }
$piece = { param($seg) $lang = if ($seg.StartsWith('en:')) { 'en' } else { 'es' }; $t = if ($lang -eq 'en') { $seg.Substring(3) } else { $seg }; "$pdir\$($pieces["$lang|$t"]).wav" }
if ($segs.Count -eq 1) { # one piece: the clip is that recording
$pf = & $piece $segs[0]; Copy-Item $pf "$dir\$id.wav"; Copy-Item "$pf.json" "$dir\$id.wav.json"
$items[$k]["t$sp"] = @((Get-Content "$pf.json" -Raw | ConvertFrom-Json).words); $made++; continue
}
$ms = New-Object IO.MemoryStream; $times = @(); $sr = 11025
for ($s = 0; $s -lt $segs.Count; $s++) {
$pf = & $piece $segs[$s]
$b = [IO.File]::ReadAllBytes($pf); $sr = [BitConverter]::ToInt32($b, 24)
$startMs = [int]($ms.Length / 2 * 1000 / $sr)
$w = @((Get-Content "$pf.json" -Raw | ConvertFrom-Json).words)
$times += @($w | Select-Object -First ([Math]::Max(1, $w.Count - 1)) | ForEach-Object { if ($_ -ge 0) { $_ + $startMs } else { -1 } })
$ms.Write($b, 44, $b.Length - 44)
if ($s -lt $segs.Count - 1) { $z = [int]($sr * $GAP[$sp] / 1000) * 2; $ms.Write((New-Object byte[] $z), 0, $z) }
}
$times += [int]($ms.Length / 2 * 1000 / $sr)
WriteWav "$dir\$id.wav" $ms.ToArray() $sr
ConvertTo-Json @{ text = $spec; words = $times } -Compress | Set-Content -Encoding utf8 "$dir\$id.wav.json"
$items[$k]["t$sp"] = $times; $made++
}
"speed $sp : $made clips joined"
}
$out = [ordered]@{ speeds = $Speeds; items = $items }
New-Item -ItemType Directory -Force "$root\public\audio" | Out-Null
[IO.File]::WriteAllText("$root\public\audio\$Book.json", ($out | ConvertTo-Json -Depth 5 -Compress), (New-Object Text.UTF8Encoding $false))
"manifest: $root\public\audio\$Book.json"
EOF
eloq-render-es.ps1: the Eloquence renderer with a language setting (-Lang 131073 is Latin American Spanish).
cat > /root/abcbooks-spanish/eloq-render-es.ps1 <<'EOF'
# Renders text with the PC's own ETI-Eloquence (C:\Windows\Speech\Eloq\eci.dll, 32-bit) to WAV
# plus word start times (ms). Must run in 32-bit PowerShell:
# C:\Windows\SysWOW64\WindowsPowerShell\v1.0\powershell.exe -ExecutionPolicy Bypass -File eloq-render.ps1 -Jobs jobs.json
# jobs.json: [{ "text": "A cat and a rat.", "out": "C:\\...\\l1-5.wav" }, ...] -> writes out + out.json (word times)
param(
[Parameter(Mandatory)] [string]$Jobs,
[int]$Voice = 1, # 1 = Reed (Eloquence default male)
[int]$Speed = 50, # eciSpeed 0-250
[int]$Pitch = -1, # eciPitchBaseline 0-100, -1 = voice default
[int]$Rate = 1, # eciSampleRate: 0=8000, 1=11025, 2=22050
[int]$Lang = 0 # eciLanguageDialect, 0 = leave default; 0x20001 = Mexican (Latin American) Spanish
)
if ([IntPtr]::Size -ne 4) { throw 'Run this with 32-bit PowerShell (SysWOW64).' }
$env:PATH = 'C:\Windows\Speech\Eloq;' + $env:PATH
Add-Type -TypeDefinition @'
using System; using System.IO; using System.Collections.Generic; using System.Runtime.InteropServices;
public static class Eloq {
public delegate int Cb(IntPtr h, int msg, int lParam, IntPtr data);
[DllImport("eci.dll")] static extern IntPtr eciNew();
[DllImport("eci.dll")] static extern IntPtr eciDelete(IntPtr h);
[DllImport("eci.dll")] static extern int eciSetParam(IntPtr h, int p, int v);
[DllImport("eci.dll")] static extern int eciCopyVoice(IntPtr h, int from, int to);
[DllImport("eci.dll")] static extern int eciGetVoiceParam(IntPtr h, int voice, int p);
[DllImport("eci.dll")] static extern int eciSetVoiceParam(IntPtr h, int voice, int p, int v);
[DllImport("eci.dll", CharSet = CharSet.Ansi)] static extern int eciAddText(IntPtr h, string t);
[DllImport("eci.dll")] static extern int eciInsertIndex(IntPtr h, int i);
[DllImport("eci.dll")] static extern int eciSynthesize(IntPtr h);
[DllImport("eci.dll")] static extern int eciSynchronize(IntPtr h);
[DllImport("eci.dll")] static extern void eciRegisterCallback(IntPtr h, Cb cb, IntPtr data);
[DllImport("eci.dll")] static extern int eciSetOutputBuffer(IntPtr h, int size, IntPtr buf);
const int BUF = 256; // small, so word marks line up with the audio (marks arrive before the buffer is flushed)
static IntPtr buf; static List<short> pcm; static List<int> marks; static Cb keep;
static int OnMsg(IntPtr h, int msg, int lp, IntPtr d) {
if (msg == 0) { var s = new short[lp]; Marshal.Copy(buf, s, 0, lp); pcm.AddRange(s); } // eciWaveformBuffer
else if (msg == 2) { while (marks.Count <= lp) marks.Add(-1); marks[lp] = pcm.Count; } // eciIndexReply
return 1; // eciDataProcessed
}
public static int SampleRate(int r) { return r == 0 ? 8000 : r == 2 ? 22050 : 11025; }
// returns word start times in ms; writes a 16-bit mono WAV
public static int[] Render(string text, string wav, int voice, int speed, int pitch, int rate, int lang) {
IntPtr h = eciNew(); if (h == IntPtr.Zero) throw new Exception("eciNew failed (eci.dll / enu.syn not found?)");
buf = Marshal.AllocHGlobal(BUF * 2); pcm = new List<short>(); marks = new List<int>(); keep = OnMsg;
eciRegisterCallback(h, keep, IntPtr.Zero); eciSetOutputBuffer(h, BUF, buf);
if (lang != 0) eciSetParam(h, 9, lang); // eciLanguageDialect
eciSetParam(h, 5, rate); // eciSampleRate
eciSetParam(h, 1, 1); // eciInputType: annotated text
eciCopyVoice(h, voice, 0);
eciSetVoiceParam(h, 0, 6, speed); // eciSpeed
if (pitch >= 0) eciSetVoiceParam(h, 0, 2, pitch); // eciPitchBaseline
string[] words = text.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries);
for (int i = 0; i < words.Length; i++) { eciInsertIndex(h, i); eciAddText(h, words[i] + " "); }
eciInsertIndex(h, words.Length);
eciSynthesize(h); eciSynchronize(h); eciDelete(h); Marshal.FreeHGlobal(buf);
int sr = SampleRate(rate);
using (var w = new BinaryWriter(File.Create(wav))) {
int n = pcm.Count * 2;
w.Write(new[] { 'R','I','F','F' }); w.Write(36 + n); w.Write(new[] { 'W','A','V','E','f','m','t',' ' });
w.Write(16); w.Write((short)1); w.Write((short)1); w.Write(sr); w.Write(sr * 2); w.Write((short)2); w.Write((short)16);
w.Write(new[] { 'd','a','t','a' }); w.Write(n); foreach (var s in pcm) w.Write(s);
}
var t = new int[words.Length + 1];
for (int i = 0; i <= words.Length; i++) t[i] = i < marks.Count && marks[i] >= 0 ? (int)(marks[i] * 1000L / sr) : -1;
return t;
}
}
'@
$list = Get-Content $Jobs -Raw -Encoding UTF8 | ConvertFrom-Json
foreach ($j in $list) {
$t = [Eloq]::Render($j.text, $j.out, $Voice, $Speed, $Pitch, $Rate, $Lang)
ConvertTo-Json @{ text = $j.text; words = $t } -Compress | Set-Content -Encoding utf8 ($j.out + '.json')
"$($j.out) words(ms): $($t -join ',')"
}
EOF
check-lines.ps1: the pronunciation check (step 6).
cat > /root/abcbooks-spanish/check-lines.ps1 <<'EOF'
# Pronunciation check for the Spanish book: plays every recorded reading line (speed 60) into the Spanish speech model
# on CT 307 (abc-speech) and prints how many words it heard, plus the lines with misses.
# Usage: powershell -ExecutionPolicy Bypass -File ABCBooks\spanish\check-lines.ps1 [-Book silabario1861]
# Needs proxmox\check-es-lines.py next to this project, the book recorded (make-audio-es.ps1) and the server installed.
param([string]$Book = 'silabario1861')
$root = Split-Path -Parent (Split-Path -Parent $MyInvocation.MyCommand.Path)
Add-Type -AssemblyName System.Web.Extensions
$js = New-Object System.Web.Script.Serialization.JavaScriptSerializer; $js.MaxJsonLength = [int]::MaxValue
$b = $js.DeserializeObject([IO.File]::ReadAllText("$root\public\books\$Book.json", [Text.Encoding]::UTF8))
$sha = [Security.Cryptography.SHA1]::Create()
function Id($s) { (($sha.ComputeHash([Text.Encoding]::UTF8.GetBytes($s)) | Select-Object -First 6 | ForEach-Object { $_.ToString('x2') }) -join '') }
$dst = Join-Path $env:TEMP 'es-lines'; New-Item -ItemType Directory -Force $dst | Out-Null
Get-ChildItem $dst -File | Remove-Item
$rows = New-Object System.Collections.Generic.List[string]; $seen = @{}
foreach ($L in $b['lessons']) {
if ($L['title'] -notmatch '^(Lectura|Ejercicio)') { continue } # reading lines and word rows (syllable rows aren't words)
foreach ($ln in @($L['lines'])) {
if ($seen.ContainsKey($ln)) { continue }; $seen[$ln] = 1
$id = Id $L['say'][$ln]; $f = "$root\tools\out\$Book\s60\$id.wav"
if (Test-Path $f) { Copy-Item $f "$dst\$id.wav"; $rows.Add("$id.wav`t$ln") }
}
}
[IO.File]::WriteAllText("$dst\list.tsv", ($rows -join "`n"), (New-Object Text.UTF8Encoding $false))
"lines to check: $($rows.Count)"
$tar = Join-Path $env:TEMP 'es-lines.tar'
tar -cf $tar -C $dst .
scp -o BatchMode=yes $tar "$root\proxmox\check-es-lines.py" proxmox:/root/
ssh -o BatchMode=yes proxmox "pct push 307 /root/es-lines.tar /root/es-lines.tar; pct push 307 /root/check-es-lines.py /root/check-es-lines.py; pct exec 307 -- mkdir -p /root/es-lines; pct exec 307 -- tar -xf /root/es-lines.tar -C /root/es-lines; pct exec 307 -- /opt/abc-speech/venv/bin/python /root/check-es-lines.py /root/es-lines; pct exec 307 -- find /root/es-lines /root/es-lines.tar -delete; unlink /root/es-lines.tar"
EOF
arrange-spanish.ps1 (PC): sorts this flat folder into the project.
cat > /root/abcbooks-spanish/arrange-spanish.ps1 <<'EOF'
# Rebuild helper for the Spanish book: sorts the flat folder of files copied from the blog post into the ABCBooks layout.
# Usage: powershell -ExecutionPolicy Bypass -File <flat folder>\arrange-spanish.ps1 -From <flat folder> -To C:\ABCBooks
param([Parameter(Mandatory)] [string]$From, [Parameter(Mandatory)] [string]$To)
foreach ($d in 'public', 'tools', 'proxmox', 'speech', 'spanish\source\silabario1861') { New-Item -ItemType Directory -Force "$To\$d" | Out-Null }
Get-ChildItem $From -File | ForEach-Object {
$dest = switch -Regex ($_.Name) {
'^silabario1861\.txt$' { "$To\spanish\source\silabario1861"; break } # the hand-transcribed book
'^(modern-es\.json|make-book-es\.ps1|make-audio-es\.ps1|check-lines\.ps1|arrange-spanish\.ps1)$' { "$To\spanish"; break }
'^eloq-render-es\.ps1$' { "$To\tools"; break }
'^abc_speech\.py$' { "$To\speech"; break }
'\.(sh|py)$' { "$To\proxmox"; break }
default { "$To\public" } # app.js, trace.js, index.html, sw.js
}
Copy-Item $_.FullName $dest -Force
}
# Windows PowerShell 5.1 reads a script without a byte-order mark as ANSI: the Spanish letters in them need UTF-8 + BOM
Get-ChildItem "$To\spanish\*.ps1", "$To\tools\eloq-render-es.ps1" | ForEach-Object {
$t = [IO.File]::ReadAllText($_.FullName, [Text.Encoding]::UTF8); [IO.File]::WriteAllText($_.FullName, $t, (New-Object Text.UTF8Encoding $true))
}
# .sh and .py files must have Linux line endings
Get-ChildItem "$To\proxmox\*.sh", "$To\proxmox\*.py", "$To\speech\*.py" | ForEach-Object { $t = [IO.File]::ReadAllText($_.FullName) -replace "`r`n", "`n"; [IO.File]::WriteAllText($_.FullName, $t) }
Get-ChildItem "$To\spanish" -Recurse -File | Select-Object @{ n = 'File'; e = { $_.FullName.Substring($To.Length + 1) } }, Length
EOF
abc_speech.py: the reading check server, with &lang=es.
cat > /root/abcbooks-spanish/abc_speech.py <<'EOF'
#!/usr/bin/env python3
# ABC Books reading check (CT 307). The app records the kid reading one line and posts it here.
# POST /recognize?words=the+cat+is+on+the+mat[&near=0][&lang=es] body = 16 kHz mono 16-bit little-endian PCM
# -> {"words": ["the", "cat", ...], "unknown": [...]} (unknown = lesson words the model can't hear)
# Vosk only listens for the line's own words (+ [unk]), so a misread word comes back as nothing
# instead of being "corrected" to the right word. Audio is never written to disk or logged.
# lang=es uses the Spanish model (model-es, vocab-es.txt); anything else is English as before.
import json, os, re, urllib.parse
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from vosk import Model, KaldiRecognizer, SetLogLevel
SetLogLevel(-1)
HERE = os.path.dirname(os.path.abspath(__file__))
# lang -> model folder, vocabulary file, letters a word may keep
LANGS = {'en': (os.environ.get('VOSK_MODEL', '/opt/abc-speech/model'), 'vocab.txt', "a-z'"),
'es': ('/opt/abc-speech/model-es', 'vocab-es.txt', "a-záéíóúüñ")}
MODELS, BY_LEN = {}, {}
for lang, (folder, vocab, _) in LANGS.items():
if not os.path.isdir(folder):
continue
MODELS[lang] = Model(folder)
# Near-miss words: listening for only the line's words would turn a misread "rat" into the expected
# "mat". So every expected word also brings its one-letter neighbours (bat, hat, ma, mats...) from
# the books' own vocabulary, and a misread lands on one of those instead.
path = os.path.join(HERE, vocab)
words = open(path, encoding='utf-8').read().split() if os.path.exists(path) else []
BY_LEN[lang] = {}
for w in words:
if MODELS[lang].vosk_model_find_word(w) != -1:
BY_LEN[lang].setdefault(len(w), []).append(w)
def fold(w):
"""Spanish: b and v are the same sound, and the small Spanish model hears a word-start m as b
(mesa -> besa, tested 2026-10-14 with Eloquence clips that do sound different to a listener)."""
return re.sub('[mv]', 'b', w)
def neighbours(word, lang, limit=12):
"""Same-length words with one letter changed (rat -> mat, bat). Short words (a, is, the) get none:
their near misses (as, he) were stealing correctly read words in testing."""
if len(word) < 3:
return []
near = [w for w in BY_LEN[lang].get(len(word), ()) if w != word and sum(x != y for x, y in zip(word, w)) == 1]
if lang == 'es': # never offer a look-alike the Spanish model can't tell apart (mesa/besa, vaca/baca)
near = [w for w in near if fold(w) != fold(word)]
return near[:limit]
RATE = 16000
MAX_BYTES = RATE * 2 * 30 # 30 seconds
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
if urllib.parse.urlparse(self.path).path in ('/', '/health'):
self.reply(200, {'ok': True})
else:
self.reply(404, {'error': 'not found'})
def do_POST(self):
url = urllib.parse.urlparse(self.path)
if url.path != '/recognize':
return self.reply(404, {'error': 'not found'})
size = int(self.headers.get('Content-Length') or 0)
if size <= 0 or size > MAX_BYTES:
return self.reply(413, {'error': 'audio missing or too long'})
pcm = self.rfile.read(size)
query = urllib.parse.parse_qs(url.query)
raw = query.get('words', [''])[0].lower().split()
near = query.get('near', ['1'])[0] != '0' # near=0: only the given words (Ray's number answers)
lang = query.get('lang', ['en'])[0]
if lang not in MODELS:
lang = 'en'
model, letters = MODELS[lang], LANGS[lang][2]
words = sorted({w for w in (re.sub(r"[^" + letters + "]", '', w).strip("'") for w in raw) if w})[:300]
known = [w for w in words if model.vosk_model_find_word(w) != -1] # words the model can hear
grammar = set(known)
for w in known if near else ():
grammar.update(neighbours(w, lang))
rec = KaldiRecognizer(model, RATE, json.dumps(sorted(grammar) + ['[unk]'], ensure_ascii=False)) if known else KaldiRecognizer(model, RATE)
rec.SetWords(True)
rec.AcceptWaveform(pcm)
result = json.loads(rec.FinalResult())
heard = [w['word'] for w in result.get('result', []) if w['word'] != '[unk]']
if lang == 'es': # a heard m/b/v look-alike of an expected word counts as that word
same = {fold(w): w for w in known}
heard = [w if w in known else same.get(fold(w), w) for w in heard]
self.reply(200, {'words': heard, 'unknown': [w for w in words if w not in known]})
def reply(self, code, obj):
body = json.dumps(obj).encode()
self.send_response(code)
self.send_header('Content-Type', 'application/json')
self.send_header('Cache-Control', 'no-store')
self.send_header('Content-Length', str(len(body)))
self.end_headers()
self.wfile.write(body)
def log_message(self, fmt, *args):
pass # no request logs: these are kids' voices
if __name__ == '__main__':
ThreadingHTTPServer(('0.0.0.0', 8000), Handler).serve_forever()
EOF
get-vosk-es.sh: downloads the Spanish model inside CT 307 (abc-speech), once.
cat > /root/abcbooks-spanish/get-vosk-es.sh <<'EOF'
#!/bin/bash
# Run INSIDE CT 307: downloads the small Spanish Vosk model to /opt/abc-speech/model-es (once).
set -euo pipefail
NAME=vosk-model-small-es-0.42
DEST=/opt/abc-speech/model-es
if [ -d "$DEST" ]; then echo "already there: $DEST"; exit 0; fi
command -v unzip >/dev/null || apt-get install -y unzip
cd /tmp
wget -q -O "$NAME.zip" "https://alphacephei.com/vosk/models/$NAME.zip"
ls -l "$NAME.zip"
unzip -q "$NAME.zip"
mv "$NAME" "$DEST"
unlink "$NAME.zip"
chown -R abc:abc "$DEST"
du -sh "$DEST"; ls "$DEST"
EOF
install-speech-es.sh: installs the Spanish reading check on CT 307 (abc-speech).
cat > /root/abcbooks-spanish/install-speech-es.sh <<'EOF'
#!/bin/bash
# Run on the Proxmox host after copying abc_speech.py and get-vosk-es.sh to /root/abcbooks-spanish/.
# Adds Spanish to the reading check on CT 307 (abc-speech): downloads the small Spanish Vosk model once, installs the
# server that understands &lang=es, restarts it, and checks that English still answers.
set -euo pipefail
CT=307
SRC=/root/abcbooks-spanish
pct push $CT $SRC/get-vosk-es.sh /root/get-vosk-es.sh --perms 700
pct exec $CT -- bash /root/get-vosk-es.sh
pct exec $CT -- cp -p /opt/abc-speech/abc_speech.py /opt/abc-speech/abc_speech.py.bak-es
pct push $CT $SRC/abc_speech.py /opt/abc-speech/abc_speech.py --user 0 --group 0 --perms 644
if ! pct exec $CT -- /opt/abc-speech/venv/bin/python -m py_compile /opt/abc-speech/abc_speech.py; then
echo "COMPILE FAILED: putting the old server back"
pct exec $CT -- cp -p /opt/abc-speech/abc_speech.py.bak-es /opt/abc-speech/abc_speech.py
exit 1
fi
pct exec $CT -- systemctl restart abc-speech
sleep 10 # both models load at start
pct exec $CT -- systemctl is-active abc-speech
echo "English check (one second of silence, expect empty words):"
pct exec $CT -- python3 -c "import urllib.request,json;r=urllib.request.Request('http://127.0.0.1:8000/recognize?words=the+cat',data=bytes(32000),method='POST');print(json.load(urllib.request.urlopen(r)))"
echo "Spanish check (expect empty words, and no error):"
pct exec $CT -- python3 -c "import urllib.request,json;r=urllib.request.Request('http://127.0.0.1:8000/recognize?words=casa&lang=es',data=bytes(32000),method='POST');print(json.load(urllib.request.urlopen(r)))"
EOF
check-es-lines.py: runs inside CT 307 (abc-speech) for the pronunciation check.
cat > /root/abcbooks-spanish/check-es-lines.py <<'EOF'
#!/usr/bin/env python3
# Run INSIDE CT 307: /opt/abc-speech/venv/bin/python check-es-lines.py <dir>
# <dir>/list.tsv: "<clip>.wav<TAB><line as shown in the app>" per line, the WAVs next to it (Eloquence, 11025 Hz).
# For every recorded reading line:
# app = what the reading check does (POST to the live server with lang=es and the line's words): all words heard?
# free = the Spanish model with no word list: how many of the line's words it hears by itself (pronunciation check)
# Prints totals and the lines with the most misses, so a person can look at those words.
import json, os, re, struct, sys, unicodedata, urllib.parse, urllib.request
from vosk import Model, KaldiRecognizer, SetLogLevel
SetLogLevel(-1)
MODEL = Model('/opt/abc-speech/model-es')
def pcm16k(path):
data = open(path, 'rb').read(); rate = struct.unpack('<I', data[24:28])[0]
s = struct.unpack('<%dh' % ((len(data) - 44) // 2), data[44:44 + (len(data) - 44) // 2 * 2])
out = []
for i in range(int(len(s) * 16000 / rate)):
x = i * rate / 16000; j = int(x); f = x - j
a = s[j] if j < len(s) else 0; b = s[j + 1] if j + 1 < len(s) else a
out.append(int(a + (b - a) * f))
return struct.pack('<%dh' % len(out), *out)
def words(t):
return [w for w in re.sub(r"[^a-záéíóúüñ ]", ' ', t.lower().replace('-', '')).split() if w]
def fold(w): # same rule as the server: m/v heard as b; accents ignored for the free check
w = ''.join(c for c in unicodedata.normalize('NFD', w) if unicodedata.category(c) != 'Mn')
return re.sub('[mv]', 'b', w)
d = sys.argv[1]
rows = [l.rstrip('\n').split('\t') for l in open(os.path.join(d, 'list.tsv'), encoding='utf-8') if '\t' in l]
app_ok = app_n = free_ok = free_n = 0
worst = []
for wav, line in rows:
want = words(line)
pcm = pcm16k(os.path.join(d, wav))
q = urllib.parse.urlencode({'words': line, 'lang': 'es'})
r = urllib.request.Request('http://127.0.0.1:8000/recognize?' + q, data=pcm, method='POST')
res = json.load(urllib.request.urlopen(r, timeout=60))
heard = [fold(w) for w in res['words']]; unknown = set(fold(w) for w in res.get('unknown', []))
# in order, like the app (each heard word used once)
pos = 0; miss = []
for w in want:
fw = fold(w)
if fw in unknown:
app_ok += 1; continue
try:
pos = heard.index(fw, pos) + 1; app_ok += 1
except ValueError:
miss.append(w)
app_n += len(want)
rec = KaldiRecognizer(MODEL, 16000); rec.AcceptWaveform(pcm)
free = [fold(w) for w in json.loads(rec.FinalResult()).get('text', '').split()]
fh = sum(1 for w in want if fold(w) in free); free_ok += fh; free_n += len(want)
worst.append((len(miss), len(want) - fh, line, miss))
print(f'lines: {len(rows)}')
print(f'app check: {app_ok}/{app_n} words heard ({100 * app_ok / max(1, app_n):.1f}%)')
print(f'free listening: {free_ok}/{free_n} words ({100 * free_ok / max(1, free_n):.1f}%)')
worst.sort(key=lambda x: (-x[0], -x[1]))
print('--- lines with app-check misses:')
for m, fm, line, miss in [w for w in worst if w[0]][:60]:
print(f'{m} missed {miss} | {line}')
EOF
instructions-es.json: the app's instructions in Spanish (English text = key).
cat > /root/abcbooks-spanish/instructions-es.json <<'EOF'
{
"_about": "Spanish book (silabario1861): the app's English instructions in Latin American Spanish, said after the English (or alone) by a grown-up's choice on the home screen. Keys are the exact English text the app uses ({a} {b} {t} {kid} are filled in). Recorded as 'es:<English>' clips by spanish/make-audio-es.ps1. The child is spoken to as 'tú', the grown-up as 'usted'.",
"text": {
"Pick a lesson. The glowing one is next.": "Escoge una lección. La que brilla es la que sigue.",
"New words. Tap each card to hear it.": "Palabras nuevas. Toca cada tarjeta para oírla.",
"Now play the game. Listen, then tap the word you hear.": "Ahora juega. Escucha, y toca la palabra que oyes.",
"Listen. I will read the lesson to you. Watch the words light up.": "Escucha. Te voy a leer la lección. Mira cómo se iluminan las palabras.",
"Your turn to read. Tap the microphone, then read the line out loud.": "Te toca leer. Toca el micrófono, y lee la línea en voz alta.",
"Your turn to read. Read each line out loud to a grown-up. Grown-up, tap any word that was missed, then tap done.": "Te toca leer. Lee cada línea en voz alta a un adulto. Adulto, toque las palabras que faltaron, y luego toque la palomita.",
"This device cannot listen. Read it to a grown-up instead.": "Este aparato no puede escuchar. Léeselo a un adulto.",
"Time to trace. Start at the green dot, and follow the arrow.": "Hora de trazar. Empieza en el punto verde, y sigue la flecha.",
"Now trace in cursive. Start at the green dot, and follow the arrow.": "Ahora traza en letra cursiva. Empieza en el punto verde, y sigue la flecha.",
"Blackboard time. Write it yourself. Tap peek if you need help. Tap done when you finish.": "Hora del pizarrón. Escríbelo tú solo. Toca el botón de los ojos si necesitas ayuda. Toca la palomita cuando termines.",
"You finished the lesson!": "¡Terminaste la lección!",
"Good! Now play the game.": "¡Bien! Ahora juega.",
"Great job!": "¡Muy bien!",
"Great job, {kid}!": "¡Muy bien, {kid}!",
"Tap each card to hear it.": "Toca cada tarjeta para oírla.",
"Find the word ({a} of {b})": "Busca la palabra ({a} de {b})",
"Tap a word to hear it. Press and hold a word to sound it out.": "Toca una palabra para oírla. Deja el dedo en una palabra para oírla por sílabas.",
"This device has no reading voice. A grown-up can read it.": "Este aparato no tiene voz para leer. Un adulto puede leerlo.",
"Line {a} of {b}: tap the microphone, then read it out loud.": "Línea {a} de {b}: toca el micrófono, y léela en voz alta.",
"Line {a} of {b}: read it out loud. Grown-up: tap words that were missed, then ✓.": "Línea {a} de {b}: léela en voz alta. Adulto: toque las palabras que faltaron, y luego ✓.",
"Listen to the red words sounded out, then try again.": "Escucha las palabras rojas por sílabas, y vuelve a intentarlo.",
"Start at the green dot!": "¡Empieza en el punto verde!",
"Follow the arrow all the way.": "Sigue la flecha hasta el final.",
"Page {a} of {b}: start at the green dot and follow the arrow.": "Página {a} de {b}: empieza en el punto verde y sigue la flecha.",
"Write: \"{t}\" ({a} of {b})": "Escribe: \"{t}\" ({a} de {b})",
"Yes! ⭐": "¡Sí! ⭐",
"Good try!": "¡Buen intento!",
"I did not hear you. Try again!": "No te oí. ¡Inténtalo otra vez!",
"Great job! ⭐": "¡Muy bien! ⭐",
"Write it on the board first!": "¡Primero escríbelo en el pizarrón!",
"New words": "Palabras nuevas",
"Listen": "Escucha",
"Your turn to read": "Te toca leer",
"Trace: print": "Trazar: letra de molde",
"Trace: cursive": "Trazar: letra cursiva",
"Blackboard": "Pizarrón"
},
"spoken": {
"Find {w}": "Busca la palabra: {w}",
"Write: {t}": "Escribe: {t}",
"lesson:{n}": "Lección {n}."
}
}
EOF
app.js: the app, with the Spanish book on the shelf, the gentle/original switch, the instructions switch, Spanish sound-it-out, lang=es and the 3-of-4 rule.
cat > /root/abcbooks-spanish/app.js <<'EOF'
// ABC Books: gamified McGuffey lessons. All progress is saved per kid in localStorage ('abcbooks').
const BOOKS = {};
const App = (() => {
const $ = id => document.getElementById(id);
// Readers are added by a grown-up on the home screen. Devices that used ABC Books before that existed
// keep the first family's three readers.
const LEGACY_KIDS = ['Layla', 'Lavon', 'Lilly'];
let P = {}; // spoken instructions (prompts.json, also recorded by tools/make-audio.ps1)
const SHELF = [
{ id: 'primer1881' },
{ id: 'first1879' },
{ id: 'second1879' },
{ id: 'third1879' },
{ id: 'fourth1879' },
{ id: 'fifth1879' },
{ id: 'sixth1879' },
{ id: 'spell1867' },
{ id: 'silabario1861' } // Spanish (Puerto Rico, 1861): book.lang 'es', words shown with syllable hyphens
];
const STEPS = [
{ id: 'words', name: 'New words', icon: 'A' },
{ id: 'spell', name: 'Spell it', icon: '🔤' },
{ id: 'listen', name: 'Listen', icon: '👂' },
{ id: 'read', name: 'Your turn to read', icon: '🎤' },
{ id: 'talk', name: 'Talk about it', icon: '💬' },
{ id: 'print', name: 'Trace: print', icon: 'a' },
{ id: 'script', name: 'Trace: cursive', icon: '𝒶' },
{ id: 'board', name: 'Blackboard', icon: '✎' }
];
// ---------- storage ----------
let S = load();
function load() {
let s = null; try { s = JSON.parse(localStorage.getItem('abcbooks')); } catch (e) {}
s = s || { kid: null, progress: {}, boards: {} };
if (!s.speed) s.speed = 60;
if (!Array.isArray(s.readers)) {
s.readers = (s.kid || Object.keys(s.progress).length) ? LEGACY_KIDS.slice() : [];
for (const n of Object.keys(s.progress)) if (!s.readers.includes(n)) s.readers.push(n);
}
if (!s.kid) { try { const c = JSON.parse(localStorage.getItem('blockcraft_cfg')); if (c && s.readers.includes(c.name)) s.kid = c.name; } catch (e) {} }
return s;
}
function save() { try { localStorage.setItem('abcbooks', JSON.stringify(S)); } catch (e) { trimBoards(); try { localStorage.setItem('abcbooks', JSON.stringify(S)); } catch (e2) {} } }
function trimBoards() { for (const k in S.boards) S.boards[k] = S.boards[k].slice(-12); }
const prog = (book) => ((S.progress[S.kid] = S.progress[S.kid] || {})[book] = (S.progress[S.kid] || {})[book] || {});
// ---------- sound + voice ----------
let AC;
function beep(good) {
try {
AC = AC || new (window.AudioContext || window.webkitAudioContext)();
const o = AC.createOscillator(), g = AC.createGain(), t = AC.currentTime;
o.type = 'triangle'; o.connect(g); g.connect(AC.destination);
const notes = good ? [523, 659, 784] : [220, 180];
notes.forEach((f, i) => o.frequency.setValueAtTime(f, t + i * 0.1));
g.gain.setValueAtTime(0.25, t); g.gain.exponentialRampToValueAtTime(0.001, t + notes.length * 0.1 + 0.2);
o.start(t); o.stop(t + notes.length * 0.1 + 0.25);
} catch (e) {}
}
const hasTTS = 'speechSynthesis' in window;
let voice = null;
function pickVoice() {
if (!hasTTS) return;
const v = speechSynthesis.getVoices().filter(v => /^en/i.test(v.lang));
voice = v.find(v => /female|samantha|zira|aria|jenny|google us/i.test(v.name)) || v[0] || null;
}
if (hasTTS) { pickVoice(); speechSynthesis.onvoiceschanged = pickVoice; }
// Recorded Eloquence clips (tools/make-audio.ps1): audio/<book>.json maps text -> clip id + word start times per speed.
const AUDIO = {};
const clip = new Audio(); clip.preload = 'auto';
let clipDone = null, clipRaf = 0, unlocked = false, seqTok = 0, playTok = 0;
function stopAll() { seqTok++; stopSpeech(); } // stop anything playing, including a word-by-word sequence
// ---------- sound it out (phonics chunks, recorded by tools/make-soundouts.ps1) ----------
// The word big on screen, letter teams (st, sh, igh, ing...) in red, a silent e grey; Eloquence says the word, then each
// chunk's phonics sound while the highlight moves slowly from chunk to chunk.
const SO = {};
const loadSO = b => (b in SO) ? SO[b] : (SO[b] = fetch(`audio/${b}.soundout.json`).then(r => r.ok ? r.json() : null).catch(() => null));
// a word as the recordings know it: letters (any language) and apostrophes; hyphens kept for the Spanish syllable books
const isEs = () => !!(book && book.lang === 'es');
const clean = w => w.replace(isEs() ? /[^\p{L}'-]/gu : /[^A-Za-z']/g, '').replace(/^-+|-+$/g, '');
// Spanish (the 1861 Silabario): the word, then one syllable at a time, like the book teaches (ca... sa... casa)
async function soundOutEs(word) {
const w = clean(word), key = w.replace(/-/g, '').toLowerCase(); if (!key) return;
const parts = (book.syl && book.syl[key]) || w.toLowerCase().split('-');
stopAll();
const ov = el('div', 'so'), row = el('div', 'sow'); ov.appendChild(row);
const spans = parts.map(t => { const s = el('span', '', esc(t)); row.appendChild(s); return s; });
ov.appendChild(el('div', 'hint', 'Listen: one syllable at a time, then the word. Tap to close.'));
document.body.appendChild(ov);
const my = seqTok; let stop = false; ov.onclick = () => { stop = true; stopAll(); };
try {
for (let i = 0; i < parts.length && !stop && my === seqTok; i++) {
spans.forEach((s, k) => s.classList.toggle('on', k === i));
const c = clipFor('syl:' + parts[i]); if (c) await playClip(c); else await ttsSpeak(parts[i]);
await new Promise(r => setTimeout(r, 350));
}
spans.forEach(s => s.classList.add('on'));
if (!stop && my === seqTok) { const c = clipFor(w) || clipFor(key); if (c) await playClip(c); }
} finally { setTimeout(() => ov.remove(), 400); }
}
// ---------- instructions in the Spanish book: English, then Spanish (or one of them; a grown-up's switch, S.ins) ----------
// instructions-es.json maps the app's English text to Spanish; the Spanish clips are recorded as 'es:<English text>'.
// Only for books with lang 'es': the English books never change.
let ES = {};
const insMode = () => isEs() ? (S.ins || 'both') : 'en';
const fill = (t, v) => v ? t.replace(/\{(\w+)\}/g, (m, k) => (k in v ? v[k] : m)) : t;
function hint(key, v) {
const en = fill(key, v), es = ES[key] && fill(ES[key], v), m = insMode(), h = $('hint');
if (m === 'en' || !es) h.textContent = en;
else if (m === 'es') h.textContent = es;
else h.innerHTML = esc(en) + '<br><span class="es">' + esc(es) + '</span>';
}
function toastI(key, ms) {
const es = ES[key], m = insMode();
if (m === 'en' || !es) return toast(key, ms);
toast(m === 'es' ? es : key, ms);
if (m === 'both') $('toast').innerHTML = esc(key) + '<br>' + esc(es);
}
const titleI = name => { const es = ES[name], m = insMode(); return m === 'en' || !es ? name : m === 'es' ? es : `${name} · ${es}`; };
// say an instruction: English, a short pause, then Spanish; stops if anything else starts speaking meanwhile
async function sayIns(key) {
const m = insMode(), esKey = 'es:' + key;
if (m === 'en' || !clipFor(esKey)) return speak(key);
if (m === 'es') return speak(esKey);
await speak(key);
const t = seqTok; await new Promise(r => setTimeout(r, 250));
if (t === seqTok) return speak(esKey);
}
async function soundOut(word) {
if (isEs()) return soundOutEs(word);
const w = word.replace(/[^A-Za-z']/g, '').toLowerCase(); if (!w) return;
const m = await loadSO(bookId), e = m && m.words[w];
if (!e) return speak(word.replace(/[^A-Za-z']/g, ''), null, 0.7);
stopAll();
const ov = el('div', 'so'), row = el('div', 'sow'); ov.appendChild(row);
const spans = e.ch.map(([t, k]) => { const s = el('span', (t.length > 1 ? 'multi' : '') + (k === 'S' ? ' silent' : ''), t); row.appendChild(s); return s; });
ov.appendChild(el('div', 'hint', 'Listen: the word, then each sound. Tap to close.'));
ov.onclick = () => stopAll();
document.body.appendChild(ov);
const my = ++playTok;
try {
const u = await clipURL(`audio/so/s${m.speed}/${e.id}.mp3`);
if (my !== playTok) return;
await new Promise(res => {
clipDone = res; clip.muted = false; clip.src = u;
const tick = () => {
const ms = clip.currentTime * 1000; let cur = -1;
e.ch.forEach(([, , t], i) => { if (t >= 0 && ms >= t - 80) cur = i; });
spans.forEach((s, i) => s.classList.toggle('on', i === cur));
clipRaf = setTimeout(tick, 60); // a timer, not animation frames: keeps stepping even on a busy tablet
};
clip.onended = clip.onerror = () => { clearTimeout(clipRaf); if (clipDone) { const d = clipDone; clipDone = null; d(); } };
clip.play().then(tick).catch(() => clip.onended());
});
} catch (err) { } finally { setTimeout(() => ov.remove(), 500); }
}
// press and hold a word (about half a second) to sound it out; a quick tap still does what it did before
function holdToSound(node, word) {
let t = 0, held = false;
node.addEventListener('pointerdown', () => { held = false; clearTimeout(t); t = setTimeout(() => { held = true; soundOut(word); }, 550); });
['pointerup', 'pointerleave', 'pointercancel'].forEach(ev => node.addEventListener(ev, () => clearTimeout(t)));
node.addEventListener('click', e => { if (held) { e.stopImmediatePropagation(); e.preventDefault(); held = false; } }, true);
node.addEventListener('contextmenu', e => e.preventDefault());
}
function stopSpeech() {
cancelAnimationFrame(clipRaf); clearTimeout(clipRaf); clip.pause();
if (clipDone) { const d = clipDone; clipDone = null; d(); }
if (hasTTS) speechSynthesis.cancel();
}
// look in the open book's recordings first, then any other book's (the home screen has no book open yet)
function clipFor(key) {
for (const b of [bookId, ...Object.keys(AUDIO)]) {
const m = AUDIO[b]; if (!m) continue;
const c = m.items[key] || (!/\s/.test(key) && m.items[key.toLowerCase()]);
if (c) return { ...c, book: b };
}
}
const speedOf = b => AUDIO[b].speeds.includes(S.speed) ? S.speed : AUDIO[b].speeds[0];
function playClip(c, onWord) {
stopSpeech();
const sp = speedOf(c.book), t = c['t' + sp] || [];
const my = ++playTok;
return new Promise(res => {
clipDone = () => { onWord && onWord(-1); res(); };
clipURL(`audio/${c.book}/s${sp}/${c.id}.mp3`).then(u => {
if (my !== playTok) return;
clip.muted = false; clip.src = u;
const tick = () => {
const ms = clip.currentTime * 1000 + 40; let i = 0;
for (let k = 0; k < t.length - 1; k++) if (t[k] >= 0 && ms >= t[k]) i = k;
onWord(i); clipRaf = requestAnimationFrame(tick);
};
clip.onended = clip.onerror = () => { cancelAnimationFrame(clipRaf); if (clipDone) { const d = clipDone; clipDone = null; d(); } };
clip.play().then(() => { if (onWord) tick(); }).catch(() => clip.onended());
}).catch(() => { if (my === playTok && clipDone) { const d = clipDone; clipDone = null; d(); } });
});
}
// Clips are fetched whole and played from memory: works with the offline cache on every browser (Safari needs this).
const blobs = new Map();
function clipURL(url) {
if (!blobs.has(url)) blobs.set(url, fetch(url).then(r => { if (!r.ok) throw new Error(r.status); return r.blob(); }).then(b => URL.createObjectURL(b)));
const p = blobs.get(url); p.catch(() => blobs.delete(url)); return p;
}
// warm up a lesson's clips so the first tap answers right away
function preload(L) {
if (!AUDIO[bookId]) return;
[...L.lines, ...L.words, ...L.letters.map(t => 'letter:' + t)].forEach(k => { const c = clipFor(k); if (c) clipURL(`audio/${c.book}/s${speedOf(c.book)}/${c.id}.mp3`).catch(() => {}); });
}
// Safari/iOS only allows audio that starts inside a tap: play a muted clip on the first tap to unlock it.
window.addEventListener('pointerdown', () => {
const c = clipFor('Yes!'); if (unlocked || !c) return; unlocked = true;
const src = `audio/${c.book}/s${AUDIO[c.book].speeds[0]}/${c.id}.mp3`;
if (!clip.src) { clip.muted = true; clip.src = src; clip.play().then(() => { if (clip.src.endsWith(src)) { clip.pause(); } clip.muted = false; }).catch(() => { clip.muted = false; }); }
}, { passive: true });
// speak(text, onWord(i)) -> Promise: recorded clip if there is one, else one clip per word, else the device voice.
async function speak(text, onWord, rate) {
seqTok++;
const c = clipFor(text);
if (c) return playClip(c, onWord);
const parts = text.split(/[\s,]+/).map(clean).filter(Boolean);
if (parts.length && parts.every(w => clipFor(w))) {
const my = seqTok;
for (let i = 0; i < parts.length && my === seqTok; i++) { await playClip(clipFor(parts[i]), onWord ? j => onWord(j < 0 ? -1 : i) : null); }
return;
}
return ttsSpeak(text, onWord, rate);
}
// device voice fallback. Word highlight uses boundary events, or a timing guess when the device has none.
function ttsSpeak(text, onWord, rate) {
return new Promise(res => {
const words = text.split(/\s+/);
if (!hasTTS) { // no voice: still step through the words so the highlight works
let i = 0; const t = setInterval(() => { onWord && onWord(i); if (++i > words.length) { clearInterval(t); res(); } }, 550); return;
}
speechSynthesis.cancel();
const u = new SpeechSynthesisUtterance(text.replace(/;/g, ','));
if (voice) u.voice = voice; u.rate = rate || 0.8; u.pitch = 1.05;
let gotBoundary = false, guess;
const starts = []; let p = 0; for (const w of words) { starts.push(p); p += w.length + 1; }
u.onboundary = e => {
if (e.name && e.name !== 'word') return; gotBoundary = true; clearInterval(guess);
let i = starts.findIndex((s, k) => e.charIndex >= s && (k === starts.length - 1 || e.charIndex < starts[k + 1]));
onWord && i >= 0 && onWord(i);
};
u.onstart = () => {
if (!onWord) return; let i = 0; onWord(0);
guess = setInterval(() => { if (gotBoundary) return clearInterval(guess); if (++i < words.length) onWord(i); }, 620 / (rate || 0.8) * 0.8);
};
u.onend = u.onerror = () => { clearInterval(guess); onWord && onWord(-1); res(); };
speechSynthesis.speak(u);
});
}
// ---------- speech recognition ----------
// First choice: record the line and let our Vosk server (CT 307, api/recognize) check it. It only
// listens for the line's words, so misreads are caught instead of auto-corrected. Fallback: the browser's own.
const SR = window.SpeechRecognition || window.webkitSpeechRecognition;
const GUM = !!(window.isSecureContext && navigator.mediaDevices && navigator.mediaDevices.getUserMedia);
let useMic = GUM || !!SR; // turned off for good if the browser blocks the mic (Amazon Kids browser says not-allowed)
let useServer = GUM, lastUnknown = [], stopListening = () => {};
const SOUNDS_LIKE = { a: ['uh', 'ah', 'the'], ann: ['an', 'anne', 'and'], nat: ['gnat', 'matt', 'nate'], rat: ['ratt'], cat: ['kat'], rab: ['rob', 'rabb'] };
// the speech checker answers without syllable hyphens and (Spanish) treats m, b and v alike
const norm = w => { const n = w.toLowerCase().replace(/[^\p{L}']/gu, ''); return isEs() ? n.replace(/[mv]/g, 'b') : n; };
function same(a, b) { return a === b || (SOUNDS_LIKE[a] || []).includes(b); }
// which expected words were heard, in order (LCS alignment)
function matchWords(expected, heard) {
const E = expected.map(norm), H = heard.map(norm).filter(Boolean);
const m = E.length, n = H.length, d = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
for (let i = m - 1; i >= 0; i--) for (let j = n - 1; j >= 0; j--) d[i][j] = same(E[i], H[j]) ? d[i + 1][j + 1] + 1 : Math.max(d[i + 1][j], d[i][j + 1]);
const ok = new Array(m).fill(false); let i = 0, j = 0;
while (i < m && j < n) { if (same(E[i], H[j])) { ok[i] = true; i++; j++; } else if (d[i + 1][j] >= d[i][j + 1]) i++; else j++; }
return ok;
}
async function listen(timeoutMs, line) {
lastUnknown = [];
if (useServer) {
try { return await listenServer(timeoutMs, line); }
catch (e) { useServer = false; if (!SR) { useMic = false; return []; } } // server down: browser recognizer if any
}
return listenBrowser(timeoutMs);
}
// Records until the kid stops talking (1.3 s quiet), the time runs out, or the mic is tapped again.
async function listenServer(timeoutMs, line) {
let stream;
try { stream = await navigator.mediaDevices.getUserMedia({ audio: { echoCancellation: true, noiseSuppression: true } }); }
catch (e) { useMic = false; return []; }
const ctx = new (window.AudioContext || window.webkitAudioContext)();
try { await ctx.resume(); } catch (e) {}
const src = ctx.createMediaStreamSource(stream), proc = ctx.createScriptProcessor(4096, 1, 1);
const chunks = []; let spoke = false, quiet = 0; const t0 = performance.now();
await new Promise(res => {
let done = false; const fin = () => { if (!done) { done = true; res(); } };
stopListening = fin;
proc.onaudioprocess = e => {
const d = e.inputBuffer.getChannelData(0); chunks.push(new Float32Array(d));
let s = 0; for (let i = 0; i < d.length; i++) s += d[i] * d[i];
if (Math.sqrt(s / d.length) > 0.015) { spoke = true; quiet = 0; } else if (spoke) quiet += d.length / ctx.sampleRate * 1000;
if ((spoke && quiet > 1300) || performance.now() - t0 > timeoutMs) fin();
};
src.connect(proc); proc.connect(ctx.destination);
});
stopListening = () => {};
proc.disconnect(); src.disconnect(); stream.getTracks().forEach(t => t.stop());
const rate = ctx.sampleRate; ctx.close();
if (!spoke) return [];
const r = await fetch('api/recognize?words=' + encodeURIComponent(line) + (isEs() ? '&lang=es' : ''), { method: 'POST', headers: { 'Content-Type': 'application/octet-stream' }, body: to16k(chunks, rate) });
if (!r.ok) throw new Error('speech ' + r.status);
const j = await r.json(); lastUnknown = j.unknown || [];
return [j.words || []];
}
// Float32 at the device rate -> 16 kHz 16-bit PCM (what Vosk wants)
function to16k(chunks, rate) {
const n = chunks.reduce((a, c) => a + c.length, 0), all = new Float32Array(n); let o = 0;
for (const c of chunks) { all.set(c, o); o += c.length; }
const ratio = rate / 16000, m = Math.floor(n / ratio), out = new Int16Array(m);
for (let i = 0; i < m; i++) {
const a = Math.floor(i * ratio), b = Math.min(n, Math.max(a + 1, Math.floor((i + 1) * ratio))); let s = 0;
for (let k = a; k < b; k++) s += all[k];
out[i] = Math.max(-1, Math.min(1, s / (b - a))) * 32767;
}
return out.buffer;
}
function listenBrowser(timeoutMs) {
return new Promise(res => {
const r = new SR(); r.lang = isEs() ? 'es-US' : 'en-US'; r.interimResults = false; r.maxAlternatives = 3;
let best = [], done = false;
const fin = () => { if (done) return; done = true; try { r.stop(); } catch (e) {} res(best); };
r.onresult = e => { const alts = [...e.results[0]].map(a => a.transcript.split(/\s+/)); best = alts; fin(); };
r.onerror = e => { if (/not-allowed/.test(e.error || '')) useMic = false; fin(); };
r.onend = fin;
try { r.start(); } catch (e) { useMic = false; return fin(); }
setTimeout(fin, timeoutMs || 9000);
});
}
// ---------- helpers ----------
function toast(t, ms) { const el = $('toast'); el.textContent = t; el.style.display = 'block'; clearTimeout(toast.t); toast.t = setTimeout(() => el.style.display = 'none', ms || 1200); }
function el(tag, cls, html) { const e = document.createElement(tag); if (cls) e.className = cls; if (html != null) e.innerHTML = html; return e; }
function btn(label, fn, cls) { const b = el('button', cls, label); b.onclick = fn; return b; }
const shuffle = a => { a = a.slice(); for (let i = a.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [a[i], a[j]] = [a[j], a[i]]; } return a; };
const starStr = n => '★'.repeat(n) + '☆'.repeat(3 - n);
function go(id) { document.querySelectorAll('.screen').forEach(s => s.classList.toggle('on', s.id === id)); if (id === 'home') home(); }
// ---------- readers (who is reading) ----------
// Names stay on this device only. "Edit" lets a grown-up remove a reader (two taps; their stars go too).
let addingReader = false, editingReaders = false;
const esc = t => t.replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]);
// say a line with the kid's name if the voice can say the name, else the line without it
const canSay = t => hasTTS || !!clipFor(t) || t.split(/[\s,]+/).map(clean).filter(Boolean).every(w => clipFor(w));
const sayWithName = (withName, without) => speak(canSay(withName) ? withName : without);
// Signed in to a family account (/account/sync.js): the names come from the account, so they are added
// and removed on the account page instead.
const acct = () => window.ABCAccount && window.ABCAccount.signedIn;
function readerButtons() {
const k = $('kids'); k.innerHTML = '';
$('whoHint').textContent = S.readers.length ? '' : 'Grown-ups: add each learner (a first name or nickname).';
if (acct()) {
$('whoHint').innerHTML = S.readers.length ? '' : 'Grown-ups: add your learners on the <a href="/account/">account page</a>.';
S.readers.forEach(n => k.appendChild(btn(esc(n), () => { S.kid = n; save(); home(); sayWithName(P.greet.replace('{kid}', n), P.greetAny); }, S.kid === n ? 'sel' : '')));
if (S.readers.length) { const a = el('a', 'parent', 'Add or remove learners'); a.href = '/account/'; k.appendChild(a); }
return;
}
S.readers.forEach(n => {
const b = btn(esc(n), () => {
if (!editingReaders) { S.kid = n; save(); home(); return sayWithName(P.greet.replace('{kid}', n), P.greetAny); }
if (!b.dataset.sure) { b.dataset.sure = 1; b.textContent = `Remove ${n}? Tap again`; return; }
S.readers = S.readers.filter(x => x !== n); delete S.progress[n]; delete S.boards[n];
if (S.kid === n) S.kid = null;
save(); home();
}, (S.kid === n ? 'sel' : '') + (editingReaders ? ' edit' : ''));
k.appendChild(b);
});
if (addingReader || !S.readers.length) {
const inp = el('input'); inp.maxLength = 16; inp.placeholder = 'Name'; inp.autocomplete = 'off';
const add = () => {
const n = inp.value.trim().replace(/\s+/g, ' ');
if (!/^\p{L}[\p{L}' .-]{0,15}$/u.test(n)) return toast('Letters only, please');
if (S.readers.some(x => x.toLowerCase() === n.toLowerCase())) return toast(`${n} is already here`);
S.readers.push(n); S.kid = n; addingReader = false; save(); home();
};
inp.onkeydown = e => { if (e.key === 'Enter') add(); };
k.append(inp, btn('Add', add, 'big'));
if (S.readers.length) k.appendChild(btn('Cancel', () => { addingReader = false; home(); }, 'ghost parent'));
setTimeout(() => inp.focus(), 50);
} else if (!editingReaders && S.readers.length < 8) k.appendChild(btn('+', () => { addingReader = true; home(); }, 'add'));
if (S.readers.length && !addingReader) k.appendChild(btn(editingReaders ? 'Done' : 'Edit', () => { editingReaders = !editingReaders; home(); }, 'ghost parent'));
}
// ---------- home + map ----------
function home() {
readerButtons();
const sb = $('speedBtn'); sb.textContent = 'Voice: ' + (S.speed === 75 ? 'faster' : 'normal');
sb.onclick = () => { S.speed = S.speed === 75 ? 60 : 75; save(); home(); bookId = bookId || 'primer1881'; speak('Great job!'); };
// Grown-ups: the Spanish 1861 stories come with gentler words by default (a few unkind words and views of the
// time were changed); this switches to the book's original wording.
const ob = $('origBtn'), hasOrig = Object.values(BOOKS).some(b => b.lessons.some(L => L.linesOrig));
ob.hidden = !hasOrig; ob.textContent = 'Spanish stories: ' + (S.orig ? 'original 1861 words' : 'gentle words');
ob.onclick = () => { S.orig = !S.orig; save(); home(); toast(S.orig ? 'Original 1861 wording' : 'Gentle wording', 1600); };
// Grown-ups: instructions in the Spanish book in English then Spanish, English only, or Spanish only (once kids know the routine)
const ib = $('insBtn'), INS = { both: 'English + Spanish', en: 'English only', es: 'Spanish only' }, order = ['both', 'en', 'es'];
ib.hidden = !Object.values(BOOKS).some(b => b.lang === 'es');
ib.textContent = 'Spanish book instructions: ' + INS[S.ins || 'both'];
ib.onclick = () => { S.ins = order[(order.indexOf(S.ins || 'both') + 1) % 3]; save(); home(); toast(INS[S.ins], 1600); };
// The books follow the kids as they grow: each shows this kid's progress, and the one they're on glows.
// Every book stays open, so an older kid can start at their own level.
const sh = $('shelf'); sh.innerHTML = '';
const doneIn = id => { const p = (S.progress[S.kid] || {})[id] || {}; return Object.values(p).filter(x => x && x.stars).length; };
const open = SHELF.filter(b => !b.soon && BOOKS[b.id]);
const cur = S.kid && (open.slice().reverse().find(b => doneIn(b.id) > 0 && doneIn(b.id) < BOOKS[b.id].lessons.length)
|| open.find(b => doneIn(b.id) < BOOKS[b.id].lessons.length));
SHELF.forEach(b => {
const B = BOOKS[b.id] || b;
const n = BOOKS[b.id] ? BOOKS[b.id].lessons.length : 0, d = S.kid && n ? doneIn(b.id) : 0;
const prog = n && S.kid ? (d >= n ? '★ Finished!' : d ? `Lesson ${d + 1} of ${n}` : `Lessons: ${n}`) : '';
const e = btn(`<b>${B.title}</b><span class="bp">${prog}</span><small>${B.sub}</small>`, () => {
if (!S.kid) return toast('Pick your name first!');
if (b.soon || !BOOKS[b.id]) return toast('Coming soon!');
openBook(b.id);
}, 'book' + (b.soon ? ' soon' : '') + (cur && cur.id === b.id ? ' cur' : ''));
e.style.background = B.color; sh.appendChild(e);
});
}
let book, bookId;
// A lesson's name for the "Go to lesson" list and the tiles: its title, else its first line.
function lessonName(L) {
const s = L.title || (L.lines && L.lines[0]) || (L.words || []).slice(0, 4).join(', ');
return `${L.review ? 'Review' : 'Lesson'} ${L.n}` + (s ? ': ' + (s.length > 48 ? s.slice(0, 46) + '…' : s) : '');
}
let skipArm = null; // a locked tile tapped once: a 2nd tap within 4 s skips ahead to it
function openBook(id) {
loadSO(id);
bookId = id; book = BOOKS[id]; $('mapTitle').textContent = `${S.kid}: ${book.title}`;
const t = $('tiles'); t.innerHTML = ''; const p = prog(id);
// Lessons are open up to one past the furthest one finished, so after a skip the kid carries on from there.
let far = -1; book.lessons.forEach((L, i) => { if ((p[L.n] || {}).stars) far = i; });
const paidAt = i => window.ABCAccount && window.ABCAccount.locked('readers', i);
const begin = (L, i) => paidAt(i) ? window.ABCAccount.unlockScreen('readers') : startLesson(L);
book.lessons.forEach((L, i) => {
const st = (p[L.n] || {}).stars || 0, open = i <= far + 1, isNext = i === far + 1;
// past the free lessons without a subscription (only when the paywall is switched on)
const paid = paidAt(i);
const tile = btn(`${L.review ? 'Review<br>' + L.n : L.n}<span class="st">${st ? starStr(st) : paid ? '🔒' : ''}</span>`, () => {
if (paid) return window.ABCAccount.unlockScreen('readers');
if (!open && !st && !(skipArm && skipArm.i === i && Date.now() - skipArm.at < 4000)) {
skipArm = { i, at: Date.now() };
return toast(`${L.review ? 'Review' : 'Lesson'} ${L.n} is ahead. Tap it again to skip to it.`, 3500);
}
skipArm = null; startLesson(L);
}, 'tile' + (isNext ? ' next' : '') + (!open && !st ? ' locked' : '') + (L.review ? ' rev' : ''));
tile.title = lessonName(L) + (st ? ', done' : !open ? ', ahead' : '');
tile.setAttribute('aria-label', tile.title);
t.appendChild(tile);
});
// "Go to lesson": every lesson by name, starting on the next one; Go starts it (skipping ahead is allowed).
const sel = $('jumpSel'); sel.innerHTML = '';
book.lessons.forEach((L, i) => {
const o = el('option'); o.textContent = lessonName(L) + ((p[L.n] || {}).stars ? ' ★' : '');
o.value = i; if (i === Math.min(far + 1, book.lessons.length - 1)) o.selected = true; sel.appendChild(o);
});
$('jumpGo').onclick = () => { const i = +sel.value; begin(book.lessons[i], i); };
go('map'); sayIns(P.map);
const nx = t.querySelector('.next'); if (nx) nx.scrollIntoView({ block: 'center' });
}
// ---------- lesson flow ----------
let L, stepIdx, scores, cleanup = null, sayAgain = null;
function stepsFor(L) { return STEPS.filter(s => (s.id !== 'words' || (L.words.length + L.letters.length)) && (s.id !== 'talk' || (L.questions && L.questions.length)) && (s.id !== 'spell' || book.spell)); }
function startLesson(lesson) {
L = S.orig && lesson.linesOrig ? { ...lesson, lines: lesson.linesOrig } : lesson; // original 1861 wording if a grown-up chose it
scores = {}; stepIdx = 0; preload(L);
$('lesTitle').textContent = (L.review ? 'Review ' : 'Lesson ') + L.n;
go('lesson'); runStep();
}
function leave() { stopStep(); openBook(bookId); }
function stopStep() { stopAll(); if (cleanup) { cleanup(); cleanup = null; } }
function runStep() {
stopStep();
const steps = stepsFor(L), s = steps[stepIdx];
const dots = $('steps'); dots.innerHTML = '';
steps.forEach((x, i) => { const d = el('i', i < stepIdx ? 'done' : i === stepIdx ? 'cur' : '', x.icon); dots.appendChild(d); });
if (!s) return finish();
$('stepTitle').textContent = titleI(s.name); $('stage').innerHTML = ''; $('controls').innerHTML = ''; $('hint').textContent = '';
sayAgain = null; stepTok++;
const instr = s.id === 'read' ? (useMic ? P.readMic : P.readHelper) : P[s.id];
hint(instr);
introP = (stepIdx === 0 ? sayIns('lesson:' + L.n) : Promise.resolve()).then(() => stepTok === myTok && sayIns(instr));
const myTok = stepTok;
$('sayBtn').onclick = () => sayIns(instr).then(() => sayAgain && sayAgain());
({ words: stepWords, listen: stepListen, read: stepRead, talk: stepTalk, spell: stepSpell, print: () => stepTrace('print'), script: () => stepTrace('script'), board: stepBoard })[s.id]();
}
let introP = Promise.resolve(), stepTok = 0;
const afterIntro = fn => { const t = stepTok; introP.then(() => { if (t === stepTok) fn(); }); };
function next(stars) { scores[stepsFor(L)[stepIdx].id] = stars; stepIdx++; runStep(); }
// 1. New words: hear each card, then "find it" game
function stepWords() {
const items = [...L.letters.map(t => ({ t, letter: true })), ...L.words.map(t => ({ t }))];
const box = el('div', 'cards'); $('stage').appendChild(box);
const cards = items.map(it => { const c = btn(it.t, () => onTap(it, c), 'card' + (it.letter ? ' letter' : '')); if (!it.letter) holdToSound(c, it.t); box.appendChild(c); return c; });
let mode = 'learn', heardAll = new Set(), target = null, round = 0, misses = 0, busy = false;
const rounds = shuffle(L.words.length ? L.words : L.letters).concat(shuffle(L.words.length ? L.words : L.letters)).slice(0, 6);
hint('Tap each card to hear it.');
// a word with a meaning from the book (Fourth Reader DEFINITIONS) says the word and what it means
const def = it => !it.letter && L.defs && L.defs[it.t];
function say(it) { if (def(it)) $('hint').textContent = `${it.t}: ${def(it)}`; return speak(it.letter ? 'letter:' + it.t : def(it) ? 'def:' + it.t : it.t, null, 0.7); }
function onTap(it, c) {
if (mode === 'learn') { say(it); heardAll.add(it); c.classList.add('ok'); setTimeout(() => c.classList.remove('ok'), 500); if (heardAll.size === items.length) startGame(); return; }
if (busy) return;
if (it.t === target) { busy = true; beep(true); c.classList.add('ok'); setTimeout(() => { busy = false; c.classList.remove('ok'); nextRound(); }, 700); }
else { beep(false); misses++; c.classList.remove('no'); void c.offsetWidth; c.classList.add('no'); }
}
function startGame() {
$('controls').innerHTML = '';
$('controls').appendChild(btn('Play: Find it!', () => { mode = 'game'; $('controls').innerHTML = ''; sayIns(P.game).then(nextRound); }, 'big'));
hint('Good! Now play the game.'); sayIns('Good! Now play the game.');
}
function nextRound() {
if (round >= rounds.length) { busy = true; toastI('Great job! ⭐'); return setTimeout(() => next(misses === 0 ? 3 : misses < 3 ? 2 : 1), 900); }
target = rounds[round++]; hint('Find the word ({a} of {b})', { a: round, b: rounds.length });
sayAgain = () => sayIns('Find ' + target); sayAgain();
shuffle(cards).forEach(c => box.appendChild(c));
}
}
// 2. Listen: the book is read to you with each word lit up
function stepListen() {
const st = $('stage'); const wrap = el('div', 'text');
wrap.appendChild(el('div', 'pic', `${L.pic}<small>${L.picText}</small>`));
if (L.title) wrap.appendChild(el('h2', '', L.title));
if (L.author) { const by = el('div', 'parent', 'by ' + L.author); if (L.about) by.title = L.about; wrap.appendChild(by); }
if (L.en) wrap.appendChild(el('div', 'parent', 'In English: ' + esc(L.en))); // Spanish book: what it means, for grown-ups
if (L.note) wrap.appendChild(el('div', 'parent', 'Note for the teacher (1861): ' + esc(L.note)));
const lines = L.lines.map(t => { const d = el('div', 'ln'); t.split(' ').forEach(w => { const s = el('span', 'w', w); s.onclick = () => speak(clean(w), null, 0.7); holdToSound(s, w); d.appendChild(s); d.append(' '); }); wrap.appendChild(d); return d; });
st.appendChild(wrap);
let stop = false;
async function playAll() {
playB.disabled = true; stop = false;
for (const d of lines) {
if (stop) break;
d.classList.add('cur'); d.scrollIntoView({ block: 'nearest' });
const ws = [...d.querySelectorAll('.w')];
await speak(d.textContent.trim(), i => ws.forEach((w, k) => w.classList.toggle('hi', k === i)));
d.classList.remove('cur'); await new Promise(r => setTimeout(r, 350));
}
if (stop) return;
playB.disabled = false; nextB.disabled = false; hint('Tap a word to hear it. Press and hold a word to sound it out.');
}
const playB = btn('▶ Read to me', playAll, 'big'), nextB = btn('Next ▶', () => next(3));
nextB.disabled = true; $('controls').append(playB, nextB);
sayAgain = playAll; cleanup = () => { stop = true; stopAll(); };
if (!AUDIO[bookId] && !hasTTS) hint('This device has no reading voice. A grown-up can read it.');
afterIntro(playAll);
}
// 3. Your turn: kid reads each line; the app listens (or a grown-up marks it)
function stepRead() {
const st = $('stage'); const wrap = el('div', 'text'); st.appendChild(wrap);
let li = 0, total = 0, right = 0, tries = 0;
const lineEl = el('div', 'ln cur'); wrap.appendChild(lineEl);
const mic = btn('🎤', go, 'mic');
function show() {
lineEl.innerHTML = '';
L.lines[li].split(' ').forEach(w => { const s = el('span', 'w', w); holdToSound(s, w); lineEl.append(s, ' '); });
hint(useMic ? 'Line {a} of {b}: tap the microphone, then read it out loud.' : 'Line {a} of {b}: read it out loud. Grown-up: tap words that were missed, then ✓.', { a: li + 1, b: L.lines.length });
$('controls').innerHTML = '';
if (useMic) $('controls').appendChild(mic);
else {
[...lineEl.querySelectorAll('.w')].forEach(w => { w.classList.add('ok'); w.onclick = () => { w.classList.toggle('ok'); w.classList.toggle('miss'); }; });
$('controls').appendChild(btn('✓ Done', () => grade([...lineEl.querySelectorAll('.w')].map(w => w.classList.contains('ok'))), 'big'));
}
$('controls').appendChild(btn('🔊 Help', () => speak(L.lines[li])));
$('controls').appendChild(btn('Skip', () => next(1), 'ghost parent'));
}
async function go() {
if (mic.classList.contains('live')) return stopListening(); // second tap = "I'm done"
mic.classList.add('live');
const words = L.lines[li].split(' ');
const alts = await listen(Math.min(25000, 4000 + 700 * words.length), L.lines[li]);
mic.classList.remove('live');
if (!useMic) { hint(P.micBlocked); sayIns(P.micBlocked); return show(); }
let ok = new Array(words.length).fill(false);
for (const a of alts) { const m = matchWords(words, a); if (m.filter(Boolean).length > ok.filter(Boolean).length) ok = m; }
const unknown = lastUnknown.map(norm);
if (alts.length) ok = ok.map((v, i) => v || unknown.includes(norm(words[i]))); // words the checker can't hear count as read
// Spanish: the small Spanish checker misses some words a child reads right (tested with the book's own
// recordings: about 84% heard), so a line where 3 of 4 words were heard counts as read
if (isEs() && alts.length && ok.filter(Boolean).length >= 0.75 * ok.length) ok = ok.map(() => true);
[...lineEl.querySelectorAll('.w')].forEach((w, i) => { w.classList.toggle('ok', ok[i]); w.classList.toggle('miss', !ok[i]); });
if (!alts.length) { toastI('I did not hear you. Try again!', 1500); return; }
grade(ok);
}
function grade(ok) {
const n = ok.filter(Boolean).length; tries++;
if (n === ok.length || tries >= 2) {
total += ok.length; right += n; tries = 0;
if (n === ok.length) { beep(true); toastI('Yes! ⭐', 900); } else toastI('Good try!', 900);
setTimeout(() => { if (++li >= L.lines.length) next(right / total > 0.9 ? 3 : right / total > 0.7 ? 2 : 1); else show(); }, 1000);
} else {
beep(false); hint('Listen to the red words sounded out, then try again.');
const words = L.lines[li].split(' ').filter((w, i) => !ok[i]);
(async () => { // each missed word sounded out, the phonics way
for (const w of words) { await soundOut(w); await new Promise(r => setTimeout(r, 400)); }
if (!useMic) show();
})();
}
}
sayAgain = () => speak(L.lines[li]);
show();
}
// 1b. Spell it (Spell & Read, 1867): "Let the child spell each word in the line, then read the line."
// Tapping a word plays its spelling ("I, S. is.") so a grown-up can check.
function stepSpell() {
const wrap = el('div', 'text'); $('stage').appendChild(wrap);
const box = el('div', 'cards'); box.style.height = 'auto'; wrap.appendChild(box);
let i = 0;
function show() {
box.innerHTML = '';
L.lines[i].split(' ').forEach(w => {
// a joined word ("new-cut", "lap-dog") is spelled one part after the other
const parts = w.split('-').map(p => p.replace(/[^A-Za-z']/g, '')).filter(Boolean);
if (!parts.length) return;
const sb = btn(w, async () => { for (const p of parts) await speak('spell:' + (p === 'I' ? p : p.toLowerCase()), null, 0.7); }, 'card');
holdToSound(sb, w); box.appendChild(sb);
});
$('hint').textContent = `Line ${i + 1} of ${L.lines.length}: spell each word out loud, then read the line.`;
}
$('controls').append(btn('🔊 Line', () => speak(L.lines[i])), btn('Next line ▶', () => { if (++i < L.lines.length) show(); else next(3); }, 'big'));
sayAgain = () => speak(L.lines[i]);
show();
}
// 3b. Talk about it: the book's own questions (Fourth Reader EXERCISES), read one at a time; the kid answers out loud
function stepTalk() {
const wrap = el('div', 'text'); $('stage').appendChild(wrap);
const q = el('div', 'ln cur'); wrap.appendChild(q);
let i = 0;
function show() {
q.textContent = L.questions[i];
$('hint').textContent = `Question ${i + 1} of ${L.questions.length}: answer it out loud.`;
sayAgain = () => speak(L.questions[i]); sayAgain();
}
$('controls').append(btn('Next question ▶', () => { if (++i < L.questions.length) show(); else next(3); }, 'big'));
afterIntro(show);
}
// 4/5. Tracing pages: letters, words, then each sentence
// Copy work stays a sensible length in the long Readers: new words 6 to a page, up to 6 short sentences
// (tools/make-audio.ps1 records the same word pages).
const WORDS_PER_PAGE = 6;
function copySentences(max) {
if (L.board) return L.board.slice(0, max); // the Spanish book lists its own pages (no syllable hyphens)
const seen = new Set(), all = [];
L.lines.filter(t => t.split(' ').length > 2).forEach(t => { const k = t.toLowerCase(); if (!seen.has(k)) { seen.add(k); all.push(t); } });
const short = all.filter(t => t.length <= 70);
return (short.length ? short : all.slice(0, 1)).slice(0, max);
}
function copyItems(style) {
if (L.copy) return L.copy.slice();
if (style === 'script' && L.script) return L.script.slice(0, 6);
const out = [];
if (L.letters.length) out.push(L.letters.join(' '));
for (let i = 0; i < L.words.length; i += WORDS_PER_PAGE) out.push(L.words.slice(i, i + WORDS_PER_PAGE).join(' '));
return out.concat(copySentences(6));
}
function stepTrace(style) {
const items = copyItems(style); let i = 0, misses = 0, tracer;
const box = el('div', 'grow paper'); box.style.height = '100%'; $('stage').appendChild(box);
const cv = el('canvas'); box.appendChild(cv);
ABCTrace.Tracer.onMiss = why => { misses++; beep(false); hint(why === 'start' ? 'Start at the green dot!' : 'Follow the arrow all the way.'); };
function page() {
if (tracer) tracer.stop();
hint('Page {a} of {b}: start at the green dot and follow the arrow.', { a: i + 1, b: items.length });
sayAgain = () => speak(items[i]); cv.dataset.text = items[i];
if (i === 0) afterIntro(sayAgain); else sayAgain();
tracer =ABCTrace.Tracer(cv, items[i], style, () => {
beep(true); toast('⭐', 700);
setTimeout(() => { if (++i < items.length) page(); else next(misses < 3 ? 3 : misses < 8 ? 2 : 1); }, 800);
});
}
$('controls').appendChild(btn('Skip page', () => { if (++i < items.length) page(); else next(1); }, 'ghost parent'));
setTimeout(page, 50);
const onRes = () => page(); window.addEventListener('resize', onRes);
cleanup = () => { tracer && tracer.stop(); window.removeEventListener('resize', onRes); };
}
// 6. Blackboard: write it yourself in chalk
function stepBoard() {
const items = copySentences(3);
if (!items.length) items.push(L.lines[0]);
let i = 0, style = 'print', bb, total = 0;
const box = el('div', 'grow'); box.style.height = '100%'; $('stage').appendChild(box);
const cv = el('canvas'); cv.style.background = 'var(--board)'; box.appendChild(cv);
const styleB = btn('Print', () => { style = style === 'print' ? 'script' : 'print'; styleB.textContent = style === 'print' ? 'Print' : 'Cursive'; page(); });
$('controls').append(styleB, btn('👀 Peek', () => bb.peek(2500)), btn('↶', () => bb.undo()), btn('Erase', () => bb.erase()), btn('Done ✓', done, 'big'));
function page() {
if (bb) bb.stop();
bb = ABCTrace.Blackboard(cv, items[i], style);
hint('Write: "{t}" ({a} of {b})', { t: items[i], a: i + 1, b: items.length });
sayAgain = () => sayIns('Write: ' + items[i]);
if (i === 0) afterIntro(sayAgain); else sayAgain();
}
function done() {
if (bb.empty()) return toastI('Write it on the board first!');
const s = bb.score(); total += s;
const list = (S.boards[S.kid] = S.boards[S.kid] || []);
list.push({ book: bookId, n: L.n, text: items[i], style, img: bb.snapshot(), at: Date.now() });
if (list.length > 40) list.splice(0, list.length - 40);
save(); beep(true); toast(starStr(s), 1100);
setTimeout(() => { if (++i < items.length) page(); else next(Math.round(total / items.length)); }, 1200);
}
setTimeout(page, 50);
cleanup = () => bb && bb.stop();
}
function finish() {
const vals = Object.values(scores), stars = Math.max(1, Math.round(vals.reduce((a, b) => a + b, 0) / vals.length));
const p = prog(bookId), old = (p[L.n] || {}).stars || 0;
p[L.n] = { stars: Math.max(old, stars), steps: scores, at: Date.now() }; save();
$('stepTitle').textContent = `${S.kid}, you finished ${L.review ? 'Review' : 'Lesson'} ${L.n}!`;
$('stage').innerHTML = `<div class="text"><div class="stars">${starStr(stars)}</div></div>`;
$('hint').textContent = ''; $('controls').innerHTML = '';
$('controls').append(btn('Lessons', () => openBook(bookId), 'big'));
beep(true); (insMode() === 'en' ? sayWithName(`Great job, ${S.kid}!`, 'Great job!') : sayIns(clipFor('es:Great job, ' + S.kid + '!') ? `Great job, ${S.kid}!` : 'Great job!')).then(() => sayIns(P.done));
}
// ---------- gallery ----------
$('toGallery').onclick = () => {
const g = $('gal'); g.innerHTML = '';
const list = (S.boards[S.kid] || []).slice().reverse();
if (!list.length) g.innerHTML = '<p>No blackboard pictures yet.</p>';
list.forEach(b => { const f = el('figure'); f.style.margin = 0; const im = el('img'); im.src = b.img; f.append(im, el('figcaption', 'parent', `Lesson ${b.n} · ${b.style} · ${new Date(b.at).toLocaleDateString()}`)); g.appendChild(f); });
go('gallery');
};
// books + their recorded voice are JSON files, so the same lesson data feeds the app and the audio recorder
Promise.all([fetch('prompts.json').then(r => r.json()).then(j => { P = j; }),
fetch('instructions-es.json').then(r => r.ok ? r.json() : null).then(j => { if (j) ES = j.text; }).catch(() => {})].concat(SHELF.filter(b => !b.soon).map(b => Promise.all([
fetch(`books/${b.id}.json`).then(r => r.json()).then(j => { BOOKS[b.id] = j; }),
fetch(`audio/${b.id}.json`).then(r => r.ok ? r.json() : null).then(j => { if (j) AUDIO[b.id] = j; }).catch(() => {})
])))).then(home, () => toast('Could not load the books. Check the internet.', 4000));
// the account answer can arrive after the first draw (signed in or out on another page): redraw the home screen
if (window.ABCAccount) window.ABCAccount.ready.then(() => { if ($('home').classList.contains('on') && Object.keys(BOOKS).length) home(); });
window.addEventListener('pointerdown', () => { if (AC && AC.state === 'suspended') AC.resume(); }, { passive: true });
return { go, leave, debug: () => ({ S, L, stepIdx, scores }) };
})();
EOF
trace.js: tracing, with the accent letters and ¿ ¡.
cat > /root/abcbooks-spanish/trace.js <<'EOF'
// Tracing + blackboard engine. Uses window.HERSHEY (single-stroke fonts, y-up, baseline 0, 1000 units/em).
(function () {
const glyphCache = {};
// Spanish letters the fonts don't have: the base letter plus an accent stroke written after it (á é í ó ú ñ ü and
// capitals), ¿ and ¡ as the font's ? and ! turned upside down, and typographic punctuation as its plain look-alike.
const MARKED = { 'á': ['a', 'acute'], 'é': ['e', 'acute'], 'í': ['i', 'acute'], 'ó': ['o', 'acute'], 'ú': ['u', 'acute'],
'Á': ['A', 'acute'], 'É': ['E', 'acute'], 'Í': ['I', 'acute'], 'Ó': ['O', 'acute'], 'Ú': ['U', 'acute'],
'ñ': ['n', 'tilde'], 'Ñ': ['N', 'tilde'], 'ü': ['u', 'dots'], 'Ü': ['U', 'dots'], '¿': ['?', 'flip'], '¡': ['!', 'flip'] };
const ALIAS = { '—': '-', '–': '-', '“': '"', '”': '"', '‘': "'", '’': "'", '«': '"', '»': '"' };
function bbox(strokes) {
let x0 = 1e9, x1 = -1e9, y0 = 1e9, y1 = -1e9;
for (const st of strokes) for (const p of st) { x0 = Math.min(x0, p.x); x1 = Math.max(x1, p.x); y0 = Math.min(y0, p.y); y1 = Math.max(y1, p.y); }
return { x0, x1, y0, y1, cx: (x0 + x1) / 2 };
}
function composed(style, ch) {
const [base, mark] = MARKED[ch], g = glyph(style, base);
let strokes = g.strokes.map(st => st.map(p => ({ ...p })));
if (mark === 'flip') { // upside down, hanging below the line like a printed ¿ ¡
const b = bbox(strokes);
strokes = strokes.map(st => st.map(p => ({ x: g.adv - p.x, y: b.y0 + b.y1 - p.y - 220 })));
return { adv: g.adv, strokes };
}
// í: drop the dot of the i (its small stroke) before the accent goes on
if (base === 'i' && strokes.length > 1) { const small = st => { const b = bbox([st]); return Math.max(b.x1 - b.x0, b.y1 - b.y0) < 130; }; const keep = strokes.filter(st => !small(st)); if (keep.length) strokes = keep; }
const b = bbox(strokes), cx = b.cx, top = b.y1;
const k = top < 500 ? top / 441 : 1; // smaller marks on the short cursive letters, so í doesn't look like an l
const add = pts => strokes.push(pts.map(([dx, dy]) => ({ x: cx + dx * k, y: top + dy * k })));
if (mark === 'acute') add([[70, 200], [-30, 70]]);
else if (mark === 'tilde') add([[-110, 95], [-80, 135], [-40, 145], [0, 120], [40, 95], [80, 105], [110, 145]]);
else if (mark === 'dots') for (const d of [-75, 75]) add([[d, 138], [d - 28, 110], [d, 82], [d + 28, 110], [d, 138]]);
return { adv: g.adv, strokes };
}
function glyph(style, ch) {
const key = style + ch;
if (glyphCache[key]) return glyphCache[key];
if (!HERSHEY[style][ch] && MARKED[ch]) return (glyphCache[key] = composed(style, ch));
if (!HERSHEY[style][ch] && ALIAS[ch]) return (glyphCache[key] = glyph(style, ALIAS[ch]));
const g = HERSHEY[style][ch] || HERSHEY[style]['?'];
const strokes = []; let cur = null;
const t = (g[1] || '').trim().split(/\s+/);
for (let i = 0; i < t.length;) {
const c = t[i++];
if (c === 'M') { cur = [{ x: +t[i++], y: +t[i++] }]; strokes.push(cur); }
else if (c === 'L') cur.push({ x: +t[i++], y: +t[i++] });
else i++;
}
// school order: the round part before the stem (font draws the stem first)
if (style === 'print' && 'adgq'.includes(ch) && strokes.length > 1) strokes.push(strokes.shift());
return (glyphCache[key] = { adv: +g[0], strokes });
}
function wordWidth(style, w) { let x = 0; for (const ch of w) x += glyph(style, ch).adv; return x; }
// Lay out text into rows that fit maxW x maxH. Returns strokes in canvas px plus ruling lines.
function layout(text, style, maxW, maxH, maxScale) {
const words = text.split(/\s+/).filter(Boolean);
const space = glyph(style, ' ').adv * (style === 'script' ? 0.8 : 1);
const lineH = 1150; // units per row (ascent 800 + descent 200 + gap)
let s = maxScale, rows;
for (; s > 0.05; s *= 0.92) {
rows = []; let row = [], w = 0;
for (const wd of words) {
const ww = wordWidth(style, wd);
if (row.length && (w + space + ww) * s > maxW) { rows.push({ words: row, w }); row = []; w = 0; }
w += (row.length ? space : 0) + ww; row.push(wd);
}
if (row.length) rows.push({ words: row, w });
if (rows.length * lineH * s <= maxH && rows.every(r => r.w * s <= maxW)) break;
}
const strokes = [], rules = [];
const top = (maxH - rows.length * lineH * s) / 2;
rows.forEach((r, ri) => {
const base = top + (ri * lineH + 870) * s;
let x = (maxW - r.w * s) / 2;
rules.push({ base, x0: x - 20, x1: x + r.w * s + 20 });
r.words.forEach((wd, wi) => {
if (wi) x += space * s;
let wordStrokes = [];
for (const ch of wd) {
const g = glyph(style, ch);
for (const st of g.strokes) wordStrokes.push(st.map(p => ({ x: x + p.x * s, y: base - p.y * s })));
x += g.adv * s;
}
if (style === 'script') wordStrokes = joinStrokes(wordStrokes, 60 * s);
strokes.push(...wordStrokes);
});
});
return { strokes: strokes.map(st => resample(st, 6)), rules, scale: s, xh: 300 * s, cap: 640 * s };
}
// Cursive: glue a letter's exit stroke to the next letter's entry so a word is one pen line.
function joinStrokes(strokes, gap) {
const out = [];
for (const st of strokes) {
const last = out[out.length - 1];
if (last) { const a = last[last.length - 1], b = st[0]; if (Math.hypot(a.x - b.x, a.y - b.y) < gap) { last.push(...st.slice(1)); continue; } }
out.push(st.slice());
}
return out;
}
function resample(pts, step) {
if (pts.length < 2) return pts.slice();
const out = [pts[0]]; let carry = 0;
for (let i = 1; i < pts.length; i++) {
const a = pts[i - 1], b = pts[i], d = Math.hypot(b.x - a.x, b.y - a.y);
let t = step - carry;
while (t <= d) { out.push({ x: a.x + (b.x - a.x) * t / d, y: a.y + (b.y - a.y) * t / d }); t += step; }
carry = d - (t - step);
}
const e = pts[pts.length - 1], l = out[out.length - 1];
if (Math.hypot(e.x - l.x, e.y - l.y) > 1) out.push(e);
return out;
}
function coalesced(e) { const c = e.getCoalescedEvents ? e.getCoalescedEvents() : null; return c && c.length ? c : [e]; }
function setupCanvas(cv) {
const r = cv.getBoundingClientRect(), dpr = Math.min(2, window.devicePixelRatio || 1);
cv.width = Math.round(r.width * dpr); cv.height = Math.round(r.height * dpr);
const ctx = cv.getContext('2d'); ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
return { ctx, w: r.width, h: r.height };
}
function drawRules(ctx, L, color) {
ctx.save(); ctx.lineWidth = 2;
for (const r of L.rules) {
ctx.strokeStyle = color; ctx.setLineDash([]);
line(ctx, r.x0, r.base, r.x1, r.base); line(ctx, r.x0, r.base - L.cap, r.x1, r.base - L.cap);
ctx.setLineDash([10, 10]); line(ctx, r.x0, r.base - L.xh, r.x1, r.base - L.xh);
}
ctx.restore();
}
function line(ctx, a, b, c, d) { ctx.beginPath(); ctx.moveTo(a, b); ctx.lineTo(c, d); ctx.stroke(); }
function poly(ctx, pts, upto) {
ctx.beginPath(); const n = upto == null ? pts.length : upto + 1;
pts.slice(0, n).forEach((p, i) => i ? ctx.lineTo(p.x, p.y) : ctx.moveTo(p.x, p.y)); ctx.stroke();
}
function arrow(ctx, pts, idx, size) {
const a = pts[Math.max(0, idx)], b = pts[Math.min(pts.length - 1, idx + 3)];
const ang = Math.atan2(b.y - a.y, b.x - a.x);
ctx.beginPath(); ctx.moveTo(a.x + Math.cos(ang) * size, a.y + Math.sin(ang) * size);
ctx.lineTo(a.x + Math.cos(ang + 2.5) * size, a.y + Math.sin(ang + 2.5) * size);
ctx.lineTo(a.x + Math.cos(ang - 2.5) * size, a.y + Math.sin(ang - 2.5) * size); ctx.closePath(); ctx.fill();
}
// Guided tracing: one stroke at a time, must start at the green dot and follow the arrow.
function Tracer(cv, text, style, onDone) {
let C, L, done = 0, idx = -1, ink = [], drawing = false, tol, lw, anim = 0, raf;
function init() {
C = setupCanvas(cv);
L = layout(text, style, C.w - 40, C.h - 30, 0.32);
L.strokes.forEach(st => st.forEach(p => { p.x += 20; p.y += 15; }));
L.rules.forEach(r => { r.base += 15; r.x0 += 20; r.x1 += 20; });
lw = Math.max(8, 70 * L.scale); tol = Math.max(22, lw * 1.6);
draw();
}
function draw() {
const ctx = C.ctx; ctx.clearRect(0, 0, C.w, C.h);
drawRules(ctx, L, 'rgba(140,170,220,.45)');
ctx.lineCap = ctx.lineJoin = 'round';
L.strokes.forEach((st, i) => {
ctx.lineWidth = lw;
if (i < done) { ctx.strokeStyle = '#3fae6e'; poly(ctx, st); }
else { ctx.strokeStyle = i === done ? '#5a5a5a' : '#3a3a3a'; ctx.setLineDash(i === done ? [] : [2, lw * 0.9]); poly(ctx, st); ctx.setLineDash([]); }
});
const cur = L.strokes[done];
if (cur) {
if (idx >= 0) { ctx.strokeStyle = '#5aa2ff'; ctx.lineWidth = lw; poly(ctx, cur, idx); }
// moving guide dot shows where to go
const k = idx >= 0 ? idx : Math.floor(anim) % cur.length;
ctx.fillStyle = '#f0a020'; arrow(ctx, cur, Math.min(k, cur.length - 2), lw * 1.1);
ctx.fillStyle = '#20b050'; ctx.beginPath(); ctx.arc(cur[0].x, cur[0].y, lw * 0.9, 0, 7); ctx.fill();
}
ctx.strokeStyle = 'rgba(120,180,255,.55)'; ctx.lineWidth = Math.max(4, lw * 0.5);
if (ink.length) poly(ctx, ink);
}
function tick() { anim += 0.6; if (idx < 0) draw(); raf = requestAnimationFrame(tick); }
function pos(e) { const r = cv.getBoundingClientRect(); return { x: e.clientX - r.left, y: e.clientY - r.top }; }
function down(e) {
e.preventDefault(); const p = pos(e), cur = L.strokes[done]; if (!cur) return;
try { cv.setPointerCapture(e.pointerId); } catch (_) {}
drawing = true; ink = [p];
idx = Math.hypot(p.x - cur[0].x, p.y - cur[0].y) < tol * 1.3 ? 0 : -2; // -2 = started in wrong place
draw();
}
function move(e) {
if (!drawing) return; e.preventDefault();
const evs = coalesced(e);
const cur = L.strokes[done];
for (const ev of evs) {
const p = pos(ev); ink.push(p);
if (idx >= 0) for (let j = 1; j <= 4 && idx + 1 < cur.length; j++) {
const q = cur[idx + 1]; if (Math.hypot(p.x - q.x, p.y - q.y) < tol) idx++; else break;
}
}
draw();
}
function up() {
if (!drawing) return; drawing = false;
const cur = L.strokes[done];
const ok = idx >= 0 && idx >= cur.length - 2 - Math.floor(cur.length * 0.08);
const wrongStart = idx === -2;
ink = []; idx = -1;
if (ok) { done++; if (done >= L.strokes.length) { draw(); cancelAnimationFrame(raf); onDone && onDone(); return; } }
draw();
if (!ok && Tracer.onMiss) Tracer.onMiss(wrongStart ? 'start' : 'follow');
}
cv.onpointerdown = down; cv.onpointermove = move; cv.onpointerup = cv.onpointercancel = up;
init(); tick();
return { stop() { cancelAnimationFrame(raf); cv.onpointerdown = cv.onpointermove = cv.onpointerup = null; }, progress: () => done / L.strokes.length };
}
// Blackboard: free writing in chalk; score by comparing ink to the target text laid out in the same box.
function Blackboard(cv, text, style) {
let C, strokes = [], cur = null, L;
function init() { C = setupCanvas(cv); L = layout(text, style, C.w - 40, C.h - 30, 0.32); redraw(); }
function chalk(ctx, pts) {
for (let k = 0; k < 3; k++) {
ctx.strokeStyle = 'rgba(245,245,235,' + (0.5 - k * 0.12) + ')'; ctx.lineWidth = 7 - k * 2;
ctx.beginPath(); pts.forEach((p, i) => { const jx = Math.sin(i * 12.99 + k * 7.1) * k * 0.8, jy = Math.cos(i * 78.23 + k * 3.7) * k * 0.8; i ? ctx.lineTo(p.x + jx, p.y + jy) : ctx.moveTo(p.x + jx, p.y + jy); }); ctx.stroke();
}
}
function redraw(peek) {
const ctx = C.ctx; ctx.fillStyle = '#23402f'; ctx.fillRect(0, 0, C.w, C.h);
ctx.lineCap = ctx.lineJoin = 'round';
drawRules(ctx, shift(L), 'rgba(255,255,255,.12)');
if (peek) { ctx.strokeStyle = 'rgba(255,255,160,.5)'; ctx.lineWidth = 5; shift(L).strokes.forEach(st => poly(ctx, st)); }
strokes.forEach(st => chalk(ctx, st));
}
function shift(L0) {
if (L0._s) return L0._s;
return (L0._s = { ...L0, strokes: L0.strokes.map(st => st.map(p => ({ x: p.x + 20, y: p.y + 15 }))), rules: L0.rules.map(r => ({ base: r.base + 15, x0: r.x0 + 20, x1: r.x1 + 20 })) });
}
function pos(e) { const r = cv.getBoundingClientRect(); return { x: e.clientX - r.left, y: e.clientY - r.top }; }
cv.onpointerdown = e => { e.preventDefault(); try { cv.setPointerCapture(e.pointerId); } catch (_) {} cur = [pos(e)]; strokes.push(cur); };
cv.onpointermove = e => { if (!cur) return; e.preventDefault(); coalesced(e).forEach(ev => cur.push(pos(ev))); redraw(); };
cv.onpointerup = cv.onpointercancel = () => { cur = null; };
init();
return {
erase() { strokes = []; redraw(); },
undo() { strokes.pop(); redraw(); },
peek(ms) { redraw(true); setTimeout(() => redraw(), ms || 2500); },
empty: () => !strokes.length,
// 0..3 stars: how much of the target is covered and how much ink is on target, after fitting ink box to target box
score() {
const ink = strokes.flat(); if (ink.length < 5) return 0;
const tgt = shift(L).strokes.flat();
const bb = pts => pts.reduce((b, p) => ({ x0: Math.min(b.x0, p.x), y0: Math.min(b.y0, p.y), x1: Math.max(b.x1, p.x), y1: Math.max(b.y1, p.y) }), { x0: 1e9, y0: 1e9, x1: -1e9, y1: -1e9 });
const a = bb(ink), b = bb(tgt);
const sx = (b.x1 - b.x0) / Math.max(1, a.x1 - a.x0), sy = (b.y1 - b.y0) / Math.max(1, a.y1 - a.y0);
const fit = ink.map(p => ({ x: b.x0 + (p.x - a.x0) * sx, y: b.y0 + (p.y - a.y0) * sy }));
const tol = Math.max(18, 110 * L.scale);
const near = (p, set) => set.some(q => Math.abs(q.x - p.x) < tol && Math.abs(q.y - p.y) < tol);
const fitS = fit.filter((_, i) => i % 2 === 0), tgtS = tgt.filter((_, i) => i % 2 === 0);
const cover = tgtS.filter(p => near(p, fitS)).length / tgtS.length;
const onTarget = fitS.filter(p => near(p, tgtS)).length / fitS.length;
const v = Math.min(cover, onTarget);
return v > 0.8 ? 3 : v > 0.6 ? 2 : 1;
},
snapshot() { const t = document.createElement('canvas'); t.width = 320; t.height = Math.round(320 * C.h / C.w); t.getContext('2d').drawImage(cv, 0, 0, t.width, t.height); return t.toDataURL('image/jpeg', 0.6); },
stop() { cv.onpointerdown = cv.onpointermove = cv.onpointerup = null; }
};
}
window.ABCTrace = { Tracer, Blackboard, layout };
})();
EOF
index.html: adds the gentle/original and instructions buttons.
cat > /root/abcbooks-spanish/index.html <<'EOF'
<!doctype html>
<html lang="en"><head>
<meta charset="utf-8">
<script>
// The readers live at abcbooks.fyi/readers/ (the welcome page is at /). This page opened at the old address
// (an old offline copy or bookmark of abcbooks.fyi/index.html): go to the new one.
if (location.hostname === 'abcbooks.fyi' && !location.pathname.startsWith('/readers/')) location.replace('/readers/');
</script>
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no">
<meta name="theme-color" content="#121212">
<title>ABC Books</title>
<link rel="icon" type="image/png" sizes="192x192" href="icon-192.png">
<link rel="manifest" href="manifest.webmanifest">
<link rel="apple-touch-icon" href="icon-192.png">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black">
<meta name="apple-mobile-web-app-title" content="ABC Books">
<style>
:root{color-scheme:dark;--paper:#121212;--ink:#ececec;--soft:#a8a8a8;--accent:#d9733f;--good:#3fae6e;--bad:#ff6b5e;--board:#1d3527;--card:#1e1e1e;--line:#6a6a6a;--hi:#3a3220}
*{box-sizing:border-box;-webkit-tap-highlight-color:transparent}
html,body{margin:0;height:100%;background:var(--paper);color:var(--ink);font-family:Georgia,"Times New Roman",serif;overflow:hidden;user-select:none;-webkit-user-select:none}
.screen{position:absolute;inset:0;display:none;flex-direction:column;padding:12px 16px}
.screen.on{display:flex}
h1{margin:4px 0 10px;font-size:clamp(26px,6vmin,44px);text-align:center;letter-spacing:.04em}
h2{margin:6px 0;font-size:clamp(20px,4.5vmin,32px);text-align:center}
button{font:inherit;font-size:clamp(18px,4vmin,26px);border:3px solid var(--line);background:var(--card);color:var(--ink);border-radius:14px;padding:10px 18px;min-height:52px;cursor:pointer}
button:active{transform:scale(.96)}
button.big{background:var(--accent);color:#fff;border-color:#6b2e14;font-size:clamp(22px,5vmin,32px);padding:14px 28px}
button.ghost{border-color:transparent;background:transparent}
button:disabled{opacity:.35}
.row{display:flex;gap:10px;justify-content:center;flex-wrap:wrap;align-items:center}
.grow{flex:1;min-height:0;position:relative}
.kids button{min-width:30vmin;font-size:clamp(24px,6vmin,40px)}
.kids button.sel{background:var(--accent);border-color:var(--accent);color:#fff}
.kids button.add,.kids button.ghost,.kids button.big{min-width:0}
.kids button.edit{border-style:dashed}
.kids input{font:inherit;font-size:clamp(22px,5vmin,34px);width:min(60vw,320px);padding:8px 14px;border-radius:14px;border:3px solid var(--line);background:var(--card);color:var(--ink)}
.shelf{display:flex;gap:3vmin;justify-content:center;flex-wrap:wrap;margin-top:3vmin;padding:8px}
#home{overflow-y:auto}
.book{width:min(30vmin,190px);aspect-ratio:3/4;border-radius:6px 14px 14px 6px;color:#fff;padding:14px;display:flex;flex-direction:column;justify-content:space-between;box-shadow:inset 10px 0 0 rgba(0,0,0,.18),0 4px 10px rgba(0,0,0,.25);border:none;text-align:left}
.book b{font-size:clamp(18px,4vmin,26px)}.book small{font-size:clamp(13px,2.6vmin,17px);opacity:.9}
.book.soon{filter:grayscale(.7);opacity:.6}
.book .bp{font-size:clamp(14px,3vmin,19px);font-weight:bold;background:rgba(0,0,0,.35);border-radius:8px;padding:4px 8px;align-self:flex-start}
.book.cur{outline:5px solid #ffd166;outline-offset:3px;animation:pulse 1.6s infinite}
.map{display:grid;grid-template-columns:repeat(auto-fill,minmax(min(22vmin,120px),1fr));grid-auto-rows:min(24vmin,120px);gap:12px;overflow-y:auto;padding:6px;align-content:start}
.tile{height:100%;min-height:0;border-radius:18px;display:flex;flex-direction:column;align-items:center;justify-content:center;font-size:clamp(22px,6vmin,38px);padding:4px}
.tile .st{font-size:clamp(14px,3vmin,20px);color:#e0a000;letter-spacing:2px}
.tile.next{background:var(--hi);border-color:var(--accent);animation:pulse 1.4s infinite}
.tile.locked{opacity:.35}
.jump{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin:6px 0;font-size:clamp(16px,3.4vmin,22px)}
.jump select{flex:1;min-width:0;max-width:100%;font:inherit;padding:8px;background:var(--card);color:var(--ink);border:2px solid var(--line);border-radius:12px}
.tile.rev{font-size:clamp(16px,3.6vmin,24px)}
@keyframes pulse{50%{transform:scale(1.05)}}
.top{display:flex;align-items:center;gap:8px}
.top .t{flex:1;text-align:center;font-size:clamp(18px,4vmin,28px)}
.steps{display:flex;gap:6px;justify-content:center;margin:4px 0}
.steps i{width:clamp(18px,4vmin,28px);height:clamp(18px,4vmin,28px);border-radius:50%;border:3px solid var(--soft);font-style:normal;font-size:12px;display:flex;align-items:center;justify-content:center}
.steps i.done{background:var(--good);border-color:var(--good)}.steps i.cur{border-color:var(--accent);background:var(--hi)}
.cards{display:flex;flex-wrap:wrap;gap:12px;justify-content:center;align-content:center;height:100%}
.card{min-width:20vmin;font-size:clamp(34px,10vmin,72px);padding:8px 18px;border-radius:16px;background:var(--card)}
.card.letter{color:var(--accent)}
.card.ok{background:var(--good);color:#fff;border-color:var(--good)}.card.no{animation:shake .4s}
@keyframes shake{25%{transform:translateX(-8px)}75%{transform:translateX(8px)}}
.pic{font-size:clamp(40px,12vmin,90px);text-align:center;line-height:1.1}
.pic small{display:block;font-size:14px;color:var(--soft)}
.text{display:flex;flex-direction:column;align-items:center;gap:1.4vmin;overflow-y:auto;height:100%;justify-content:center}
.ln{font-size:clamp(26px,7vmin,52px);line-height:1.2;padding:2px 10px;border-radius:10px}
.ln.cur{background:var(--hi)}
.w{display:inline-block;padding:0 4px;border-radius:8px}
.w.hi{background:var(--accent);color:#fff}.w.ok{color:var(--good);text-decoration:underline 4px}.w.miss{color:var(--bad);text-decoration:underline wavy 3px}
canvas{position:absolute;inset:0;width:100%;height:100%;touch-action:none;border-radius:14px}
.paper{background:#1a1a1a;border:3px solid var(--line);border-radius:14px}
.hint{font-size:clamp(15px,3.2vmin,20px);color:var(--soft);text-align:center;min-height:1.4em}
.hint .es{color:#e9b872}
.toast{position:fixed;left:50%;top:40%;transform:translate(-50%,-50%);background:#2a2a2a;border:3px solid var(--accent);color:#fff;padding:18px 28px;border-radius:18px;font-size:clamp(24px,6vmin,40px);display:none;z-index:9;text-align:center}
.stars{font-size:clamp(40px,12vmin,90px);color:#e0a000;text-align:center;letter-spacing:6px}
.gallery{display:flex;flex-wrap:wrap;gap:8px;justify-content:center;overflow-y:auto}
.gallery img{width:min(44vw,260px);border-radius:8px;border:3px solid var(--board)}
.mic{width:clamp(80px,20vmin,130px);height:clamp(80px,20vmin,130px);border-radius:50%;font-size:clamp(36px,9vmin,60px);background:var(--card)}
.mic.live{background:var(--bad);color:#fff;animation:pulse 1s infinite}
.parent{font-size:15px;color:var(--soft)}
.so{position:fixed;inset:0;background:rgba(10,10,10,.92);display:flex;flex-direction:column;align-items:center;justify-content:center;z-index:30;gap:4vmin}
.sow{display:flex;gap:1.5vmin;flex-wrap:wrap;justify-content:center}
.sow span{font-size:clamp(56px,16vmin,140px);font-family:Georgia,serif;color:var(--ink);padding:0 .12em;border-radius:14px;border:4px solid transparent;transition:background .45s,border-color .45s,transform .45s}
.sow span.multi{color:#ff5a5a}
.sow span.silent{color:#777}
.sow span.on{background:var(--hi);border-color:#ffd166;transform:scale(1.12)}
.w{-webkit-touch-callout:none}
</style>
</head><body>
<section id="home" class="screen on">
<h1>ABC Books</h1>
<h2>Who is reading?</h2>
<div class="row kids" id="kids"></div>
<div class="hint" id="whoHint"></div>
<div class="shelf" id="shelf"></div>
<div class="row" style="margin-top:auto"><button class="ghost parent" id="speedBtn">Voice: normal</button><button class="ghost parent" id="origBtn" hidden>Spanish stories: gentle words</button><button class="ghost parent" id="insBtn" hidden>Spanish book instructions: English + Spanish</button><button class="ghost parent" id="toGallery">Blackboard pictures</button><a class="parent" href="check.html">Tablet check</a><a class="parent" href="/account/">Grown-ups: account</a><a class="parent" href="/math/">Math</a><a class="parent" href="/">About</a></div>
</section>
<section id="map" class="screen">
<div class="top"><button onclick="App.go('home')">◀</button><div class="t" id="mapTitle"></div></div>
<div class="jump"><label for="jumpSel">Go to lesson</label><select id="jumpSel"></select><button id="jumpGo">Go</button></div>
<div class="map grow" id="tiles"></div>
</section>
<section id="lesson" class="screen">
<div class="top"><button onclick="App.leave()">◀</button><div class="t" id="lesTitle"></div><button id="sayBtn" title="Say it again">🔊</button></div>
<div class="steps" id="steps"></div>
<h2 id="stepTitle"></h2>
<div class="grow" id="stage"></div>
<div class="hint" id="hint"></div>
<div class="row" id="controls"></div>
</section>
<section id="gallery" class="screen">
<div class="top"><button onclick="App.go('home')">◀</button><div class="t">Blackboard pictures</div></div>
<div class="gallery grow" id="gal"></div>
</section>
<div class="toast" id="toast"></div>
<script src="hershey.js"></script>
<script src="trace.js"></script>
<script src="/account/sync.js"></script>
<script src="app.js"></script>
<script>if ('serviceWorker' in navigator) navigator.serviceWorker.register('sw.js').catch(() => {});</script>
</body></html>
EOF
sw.js: the offline cache, now with the Spanish book and its instructions (abc-shell-v27).
cat > /root/abcbooks-spanish/sw.js <<'EOF'
// Offline support. App files: network first (so updates show), cached copy when offline.
// Voice clips (audio/*.mp3) never change for a given name: cache first, saved the first time they play.
// Other apps share this origin (Ray's Arithmetic at /math/ with its own 'rays-*' caches and its own SW),
// so only touch 'abc-' caches and leave /rays/ requests alone.
// The readers moved from abcbooks.fyi/ to abcbooks.fyi/readers/ (the welcome page is at / now). This same file still
// answers at /sw.js, so a device with the old whole-site worker gets this one there, and it removes itself.
const SHELL = 'abc-shell-v27', CLIPS = 'abc-clips-v1';
const RETIRED = location.hostname === 'abcbooks.fyi' && new URL(self.registration.scope).pathname === '/';
if (RETIRED) {
self.addEventListener('install', () => self.skipWaiting());
self.addEventListener('activate', e => e.waitUntil(self.registration.unregister()));
} else {
const FILES = ['./', 'index.html', 'app.js', 'prompts.json', 'trace.js', 'hershey.js', 'books/primer1881.json', 'audio/primer1881.json', 'books/first1879.json', 'audio/first1879.json', 'books/second1879.json', 'audio/second1879.json', 'books/third1879.json', 'audio/third1879.json', 'books/fourth1879.json', 'audio/fourth1879.json', 'books/fifth1879.json', 'audio/fifth1879.json', 'books/sixth1879.json', 'audio/sixth1879.json', 'books/spell1867.json', 'audio/spell1867.json', 'books/silabario1861.json', 'audio/silabario1861.json', 'instructions-es.json', 'phonics.json', 'icon-192.png', 'manifest.webmanifest'];
// one file at a time: a book that isn't on this server (a rebuild without the Spanish book) mustn't stop the rest
self.addEventListener('install', e => { e.waitUntil(caches.open(SHELL).then(c => Promise.all(FILES.map(f => c.add(f).catch(() => {})))).then(() => self.skipWaiting())); });
self.addEventListener('activate', e => {
e.waitUntil(caches.keys().then(ks => Promise.all(ks.filter(k => k.startsWith('abc-') && k !== SHELL && k !== CLIPS).map(k => caches.delete(k)))).then(() => self.clients.claim()));
});
self.addEventListener('fetch', e => {
const r = e.request, u = new URL(r.url);
// the account service (/account/, CT 308) and the reading check (/api/) always go to the network; only sync.js is kept for offline
if (r.method !== 'GET' || u.origin !== location.origin || /\/(rays|math)(\/|$)/.test(u.pathname)) return;
if (/^\/api\//.test(u.pathname) || (/^\/account(\/|$)/.test(u.pathname) && u.pathname !== '/account/sync.js')) return;
if (r.url.endsWith('.mp3')) {
e.respondWith(caches.open(CLIPS).then(c => c.match(r).then(hit => hit || fetch(r).then(res => { if (res.ok && res.status === 200) c.put(r, res.clone()); return res; }))));
return;
}
e.respondWith(fetch(r).then(res => { if (res.ok) caches.open(SHELL).then(c => c.put(r, res.clone())); return res; }).catch(() => caches.match(r)));
});
}
EOF
2. Spanish reading check on CT 307 (abc-speech)
Proxmox host shell. It downloads the model (about 40 MB) the first time, installs the server, and ends with two answers like {'words': [], 'unknown': []} (English, then Spanish):
sed -i 's/\r$//' /root/abcbooks-spanish/*.sh
bash /root/abcbooks-spanish/install-speech-es.sh
3. Files on the PC
PC (PowerShell):
scp -o BatchMode=yes -r proxmox:/root/abcbooks-spanish C:\ABCBooks-spanish-files
powershell -ExecutionPolicy Bypass -File C:\ABCBooks-spanish-files\arrange-spanish.ps1 -From C:\ABCBooks-spanish-files -To C:\ABCBooks
4. Make the lesson file
PC (PowerShell). About 30 seconds; it ends with "lessons: 206":
powershell -ExecutionPolicy Bypass -File C:\ABCBooks\spanish\make-book-es.ps1
5. Record it, upload it, check it, put it on the server
PC (PowerShell). The first run records about 7,800 Spanish pieces per speed and joins them into about 13,000 clips per speed (the book plus the Spanish instructions); later runs only record what changed (a few minutes):
powershell -ExecutionPolicy Bypass -File C:\ABCBooks\spanish\make-audio-es.ps1
powershell -ExecutionPolicy Bypass -File C:\ABCBooks\tools\push-audio.ps1 -Book silabario1861
powershell -ExecutionPolicy Bypass -File C:\ABCBooks\tools\check-audio.ps1 -Book silabario1861
It should end with "OK: every clip is on the server" (a red "silabario1861.soundout.json not found" above that is harmless: the Spanish book sounds out with its own syllable clips).
powershell -ExecutionPolicy Bypass -File C:\ABCBooks\deploy.ps1
6. Pronunciation check (optional, after any text change)
PC (PowerShell). It prints the percentage of words heard and the lines with misses:
powershell -ExecutionPolicy Bypass -File C:\ABCBooks\spanish\check-lines.ps1
Troubleshooting
- A word sounds wrong: look for it in the pronunciation check's list. Most often it's a missing accent: add it to
"accent"inmodern-es.json(same letters, only accents differ) or"spoken"(letters differ), then steps 4 and 5. - The reading check says Spanish words are wrong all the time: CT 307 (abc-speech) console:
journalctl -u abc-speech -n 50 --no-pager
It logs nothing about requests (kids' voices are never logged); look for Python errors after a restart. If model-es failed to load, the server answers Spanish with the English model: run install-speech-es.sh again.
- A clip is silent: run
check-audio.ps1 -Book silabario1861; if clips are missing,push-audio.ps1 -Book silabario1861again. - Tracing shows a "?" box: that character has no letter yet; add it to
MARKEDorALIASintrace.js. - The other logs and the Tablet check page are in the Primer post.
Gotchas hit while building this
- Windows PowerShell 5.1 and Spanish letters: a
.ps1without a byte-order mark is read as ANSI, so "á" in the script turns into "á".arrange-spanish.ps1saves the scripts as UTF-8 with BOM. - PowerShell ignores case in variable names: a loop variable
$psilently replaced$P(the part being converted), and a local$spkhid the$SPKlist in a called function (PowerShell lets functions see the caller's variables). The converter uses$PARTand distinct names. - ...and in hashtables and JSON:
@{}and[ordered]@{}treat "De" and "de" as the same key, andConvertFrom-Jsonrefuses a file with both. The scripts useOrderedDictionarywith an ordinal comparer and .NET'sJavaScriptSerializer. - An
ifthat returns an empty set gives$null(PowerShell unrolls collections): assign the set directly. - The Spanish Eloquence phonetic code is the spelling itself ("ba" = b a), so the phonics trick from the English books changes nothing, and a consonant alone can't be said (the voice reads the code symbols out loud).
- Lines of 10 to 20 seconds confuse Vosk; cut lines short.
Notes
- The voice samples the grown-up chose from are at https://abcbooks.fyi/voices-es.html.
- Outside this book (home screen, other books) and on the buttons, the app stays in English.
- Possible next books: the Arnold series (Cartilla, then Libro primero to cuarto) and Cyr's Libro segundo (already clean text on Project Gutenberg, with English under each lesson).