DeepSeek Harness Remote
DeepSeek Harness Remote 可將手機或另一台電腦與你運行 DeepSeek Harness 的電腦安全連接,隨時隨地繼續你的工作,方便隨時查看進度並繼續工作。
AI 翻譯
尚未偵測到更新。GitHub 發布版本後會顯示在這裡。
個人 GitHub 帳號下公開儲存庫的擁有者可以登入並驗證所有權,自行管理介紹,無需管理員核准。
登入並產生驗證檔案,在 30 分鐘內將其提交至此儲存庫的預設分支。其他分支或分叉儲存庫中的檔案無效。然後在下方檢查檔案,即可無需管理員核准直接管理專案。請保留檔案以維持管理權限。
如需登入,將前往登入頁面。登入後請回到此處,再次點擊所選的驗證按鈕。
在專案資料夾中執行 /nomorevibe verify,即可驗證所有權並自行管理介紹。
一分鐘完成驗證Offline-first Markdown reader as an installable PWA. Single HTML file, KaTeX math support, Android file-handling to open .md files straight from the file explorer.
翻譯準備中
取自儲存庫 README
正在載入完整 README。
檢視完整 README ↗English · Español
A standalone, offline-first Markdown reader, installable as a PWA. Single HTML file with no build dependencies, math formula support (KaTeX), diagrams (Mermaid), and integration with the OS so you can open .md files straight from other apps.
node_modules, no framework. Open it directly in a browser or serve it as-is.$inline$ and $$block$$). Offline, it falls back to a readable Unicode approximation.```mermaid blocks are drawn with Mermaid — flowcharts, sequence, class, state, Gantt. They follow the reader's theme and are redrawn in light colors before printing. If the library can't be loaded, the block stays visible as source code.```grafico (or ```chart) block turns a small comma-, semicolon- or tab-separated table into a chart, drawn by the reader itself as SVG — no library, so it works offline too. It follows the theme, prints in light colors, shows every value on hover, and has a Ver datos table view. The scatter uses a numeric horizontal axis when the categories are numbers, and the pie labels each slice with its share.[TOC] line is replaced by a linked list of the document's headings. Unlike the side panel, it prints..md files from a project in a side panel, without uploading anything to a server.owner/repo (or paste the repo URL) and it pulls every .md/.markdown/.txt file from the latest commit of a branch — no git clone, no history. Private repos work too, with a personal access token you paste once and can choose to remember in the browser. A ↻ Actualizar button re-fetches the latest commit on demand..md from WhatsApp, a file manager, Drive, or Telegram straight into the reader. On desktop (Windows, macOS, Linux, ChromeOS), it registers as a file handler and appears in the "Open with" menu.@media print stylesheet so what you read on screen prints cleanly.index.html, so it is there on a first run and on a USB copy with no connection.Online Markdown readers usually require uploading the file to some service, or are browser extensions tied to a single device. This project came out of a simple need: open a .md file from a phone without uploading it anywhere, even without internet access. The installable PWA version also works around the fact that Android has no app associated with the .md extension by default.
Try it right now at mdreader.jhproyectos.com.ar, or open index.html locally in any modern browser — double-click it, or "Open with Chrome" from the file explorer. From there:
.md/.markdown/.txt file it contains. ↻ Refresh folder picks up what changed since. In desktop Chrome and Edge the reader keeps access to the folder (showDirectoryPicker, stored in IndexedDB) and re-reads it on its own: new files are added, changed ones updated and deleted ones removed. Chrome for Android doesn't have that API, so there it falls back to the classic picker (see known limitations)..js, .json, .ts, .py, .html, .xml, .css, .java, .c, .cs, .sql, .pq and a few more): they aren't processed as Markdown but shown whole as a single highlighted code block, with the copy button. They open from a computer (picker, drag and drop, folder, or Open with); on a phone they don't appear in the Share menu, and GitHub import brings them too.On narrow screens the side panel collapses; the ☰ button in the top bar opens it, and the same pickers are also available on the empty state, next to See an example, which opens the built-in help.
This mode requires no hosting and no internet connection, and is the simplest option if you only need to read files you pick yourself from within the app.
Click Importar de GitHub, paste owner/repo or the repo's URL, and (optionally) a branch — it defaults to the repo's default branch. This does not clone the repository: it reads the file tree of the latest commit through GitHub's API and downloads only the .md, .markdown and .txt files it finds.
.md from other apps)To integrate with the operating system, the app must be served from an HTTPS origin and installed as a PWA. This is a platform restriction: an HTML file opened via file:// cannot register a service worker or a manifest, and without those the OS has no way to associate it with a file type.
The integration differs by platform, because the underlying browser APIs do:
| Platform | API | Where the app appears |
|---|---|---|
| Android | Web Share Target | The system Share sheet |
| Windows / macOS / Linux / ChromeOS | File Handling | The "Open with" menu |
| iOS | — | Not supported (see limitations) |
If you deploy somewhere other than Vercel, port the two rules in vercel.json: the Service-Worker-Allowed: / header on sw.js, and a rewrite that sends /share-target to index.html.
.md in a WhatsApp chat (or find one in a file manager or Drive) and tap Share.If you get many files on WhatsApp, you can also tap Open folder in the reader and pick Internal storage/Android/media/com.whatsapp/WhatsApp/Media/WhatsApp Documents. When a new one arrives, tap ↻ Refresh folder and pick the same folder.
On Android the entry point is Share, not "Open with" — Chrome for Android does not implement the File Handling API, so an installed PWA cannot register itself as a handler for a file extension there.
.md from the Files app. A file someone sent you over WhatsApp or Telegram has to be saved with Save to Files first.The app detects iOS and adapts on its own: it hides Open folder (webkitdirectory is declared in WebKit but the picker ignores it), drops the accept filter on the file input (the Files picker turns extension lists into system types and ends up greying everything out), promotes Open files to the primary action, and swaps the Android share instructions for the ones above. The detection is user-agent based on purpose — feature detection reports these APIs as present on iOS.
Installing matters more here than on other platforms: WebKit wipes a plain website's IndexedDB and caches after seven days without a visit, and home screen web apps are exempt.
.md files from outside the app. There, the only way in is the Open files picker inside the app itself. Full integration would require a native wrapper (WKWebView plus a declared document type), which this repo does not do..md MIME types are inconsistent. Android apps report Markdown files under several types, so share_target accepts a broad list including application/octet-stream. The trade-off is that the reader may also appear when sharing unrelated binary files.Internal storage/Android/media/com.whatsapp/WhatsApp/Media/WhatsApp Documents, with Open folder; ↻ Refresh folder picks up the ones that arrive later.webkitdirectory), which hands the page a one-time snapshot and never the folder's path. ↻ Refresh folder reopens that picker, and Chrome asks every time whether to upload all the folder's files, counting everything in it, not just the .md. Neither can be avoided from the page..md arrive as PDF. If Drive converted an uploaded .md into a Google Doc, Drive delivers it as a PDF. The reader detects it and doesn't open it; the fix is File → Download → Markdown (.md) in Docs.Installed copies update on their own without interrupting an open document. The mechanism has four parts:
sw.js serves the HTML network-first. With a connection, the server's version always wins; the cache is only an offline fallback. This is what prevents a stale version from being frozen on a device forever. Icons and the manifest use stale-while-revalidate — instant from cache, refreshed in the background.Bump VERSION at the top of sw.js and deploy. The new version is installed in the background and remains waiting while the app is in use. Once activated, its activate handler deletes older lector-md-* shell caches automatically:
const VERSION = "v0.10.0";
The share-target cache and the IndexedDB workspace are excluded from that cleanup. Therefore, deleting an old application cache never removes a loaded document. The workspace is cleared only with Clear all, by removing documents individually, or by clearing the site's browser data.
├── index.html # The full app: HTML + CSS + JS in a single file
├── ejemplo.md # Sample document in Spanish: manual and live demo of the supported syntax
├── example.md # The same sample document in English
├── ejemplo-portada.png # Illustration shown at the top of that document
├── manifest.json # PWA metadata, file_handlers and share_target
├── sw.js # Service worker: caching, updates, share-target handling
├── voz-worker.js # Optional enhanced voice (Supertonic 2) in a Web Worker
├── icon-192.png # App icon (192×192)
├── icon-512.png # App icon (512×512)
├── icon-maskable-512.png # "Maskable" variant (Android may crop it into different shapes)
└── vercel.json # Headers and the /share-target rewrite for Vercel
index.html is fully self-contained: you can copy just that one file — onto a USB stick, into an email — and it works on any machine with a browser. The other files are only needed for the installable, OS-integrated mode.
Shared files travel through a cache. Android delivers a shared file as a POST to /share-target, but static hosting can't accept a POST. The service worker intercepts it, writes the file into a short-lived cache, and redirects to the app, which picks it up and empties the cache. The manifest also declares title and text: apps like WhatsApp send a subject and a caption, and without those params Chrome turns them into a fake "shared text" file that looks like the document. The text is only opened as a document when no file arrives and it looks like content (several lines, or long); a bare file name only triggers a notice. A file without a .md extension gets one added.
The open workspace is stored locally in IndexedDB. Markdown contents are never sent to the server. The browser may remove them only if the user clears site data or the device removes site storage; the app requests persistent storage when the browser supports it.
The maskable icon has its content scaled to 80% and centered, inside the "safe zone" Android respects when cropping icons into different shapes (circle, squircle, etc. depending on the manufacturer).
Opening a file from the OS reuses the open window. file_handlers declares "launch_type": "single-client", which only groups several files opened together into one window. What keeps a second .md from opening a second window is "launch_handler": {"client_mode": "focus-existing"}: the window that's already open comes to the front and receives the file through launchQueue, and the reader shows it while the previous files stay in the list. Desktop only (Chrome and Edge 110+); with no window open, or in a browser that ignores it, the app opens a new window. On Windows, installing from Edge is recommended: the .md files then show the reader's icon.
KaTeX is optional by design. It loads from a CDN and the app degrades to a Unicode approximation if the request fails, so the reader never depends on a network call to render a document.
Mermaid is loaded on demand. The library weighs about 3 MB, so it is only requested when the open document actually contains a diagram; after that the service worker caches it and diagrams keep working offline. The parser emits the source inside a <pre> and the SVG replaces it once drawn, which makes "no library" and "invalid diagram" the same, already-readable fallback.
Prism is loaded on demand, in manual mode. The core bundle plus the seven extra grammars weigh about 38 KB, requested only when the open document has a code block in a supported language. Prism only tags tokens with classes; the colors are the reader's own CSS variables, so highlighting follows the theme without a third-party stylesheet and prints in light colors. Once highlighted, a search can't match across two differently colored tokens, the same way it can't across bold and plain text.
MIT — use it, modify it, and adapt it to whatever you need.
English · Español
Lector de archivos Markdown standalone, offline-first e instalable como PWA. Un solo archivo HTML sin dependencias de build, con soporte de fórmulas matemáticas (KaTeX), diagramas (Mermaid) e integración con el sistema operativo para abrir .md desde otras apps.
node_modules, sin framework. Se puede abrir directo en el navegador o servir como está.$inline$ y $$bloque$$). Sin conexión, cae a una aproximación en Unicode legible.```mermaid se dibujan con Mermaid: flujos, secuencias, clases, estados, Gantt. Siguen el tema del lector y se redibujan en claro antes de imprimir. Si la librería no se puede cargar, el bloque queda a la vista como código fuente.```grafico (o ```chart) convierte una tabla chica, separada por comas, punto y coma o tabulaciones, en un gráfico que dibuja el propio lector en SVG: sin librerías, así que funciona también sin conexión. Sigue el tema, se imprime en claro, muestra los valores al pasar el mouse y tiene una vista de tabla en Ver datos. La dispersión usa eje horizontal numérico cuando las categorías son números, y la torta muestra el porcentaje de cada porción.[TOC] se reemplaza por la lista de encabezados con enlaces a cada sección. A diferencia del panel lateral, sale impresa..md de un proyecto desde un panel lateral, sin subir nada a un servidor.usuario/repo (o pegá la URL) y trae todos los .md/.markdown/.txt del último commit de una rama — sin clonar, sin historial. También funciona con repos privados, con un personal access token que se pega una vez y se puede recordar en el navegador. Un botón vuelve a traer el último commit cuando quieras.Los lectores de Markdown online suelen requerir subir el archivo a algún servicio, o son extensiones de navegador atadas a un solo dispositivo. Este proyecto nació de una necesidad simple: abrir un .md desde el celular sin subirlo a ningún lado, aunque no haya internet. La versión instalable además resuelve que Android no tenga, por defecto, ninguna app asociada a la extensión .md.
Probalo ahora mismo en mdreader.jhproyectos.com.ar, o abrí index.html localmente en cualquier navegador moderno — doble clic, o "Ver con Chrome" desde el explorador de archivos. Desde ahí:
.md/.markdown/.txt que contenga. ↻ Actualizar carpeta suma lo que cambió desde entonces. En Chrome y Edge de escritorio el lector guarda el acceso a la carpeta (showDirectoryPicker, en IndexedDB) y la relee solo: suma los nuevos, actualiza los que cambiaron y saca los borrados. Chrome para Android no tiene esa API, así que ahí usa el selector clásico (ver limitaciones conocidas)..js, .json, .ts, .py, .html, .xml, .css, .java, .c, .cs, .sql, .pq y algunos más): no se procesan como Markdown, se muestran completos como un único bloque de código resaltado, con el botón de copiar. Se abren desde la computadora (selector, arrastrar y soltar, carpeta o Abrir con); en el celular no aparecen en el menú Compartir, y la importación de GitHub también los trae.En pantallas angostas el panel lateral se pliega; el botón ☰ de la barra superior lo abre, y los mismos selectores están también en la pantalla de inicio, junto a Ver un ejemplo, que abre la ayuda incorporada.
Esta forma de uso no requiere hosting, no requiere conexión, y es la más simple si solo necesitás leer archivos que ya elegís vos mismo desde la app.
Tocá Importar de GitHub, pegá usuario/repo o la URL del repo, y opcionalmente una rama (si no ponés nada, usa la rama por defecto). Esto no clona el repositorio: lee el árbol de archivos del último commit a través de la API de GitHub y descarga sólo los .md, .markdown y .txt que encuentra.
.md desde otras apps)Para integrarse con el sistema operativo, la app tiene que estar servida desde un origen HTTPS e instalada como PWA. Esto es una restricción de la plataforma: un archivo HTML abierto en file:// no puede registrar un service worker ni un manifest, y sin eso el sistema no tiene forma de asociarlo a un tipo de archivo.
La integración cambia según la plataforma, porque las APIs del navegador son distintas:
| Plataforma | API | Dónde aparece la app |
|---|---|---|
| Android | Web Share Target | El menú Compartir del sistema |
| Windows / macOS / Linux / ChromeOS | File Handling | El menú "Abrir con" |
| iOS | — | No soportado (ver limitaciones) |
Si lo desplegás fuera de Vercel, replicá las dos reglas de vercel.json: el header Service-Worker-Allowed: / sobre sw.js, y un rewrite que mande /share-target a index.html.
.md en un chat de WhatsApp (o buscalo en el explorador de archivos o en Drive) y tocá Compartir.Si te mandan muchos archivos por WhatsApp, también podés tocar Abrir carpeta en el lector y elegir Almacenamiento interno/Android/media/com.whatsapp/WhatsApp/Media/WhatsApp Documents. Cuando llegue uno nuevo, tocá ↻ Actualizar carpeta y elegí la misma carpeta.
En Android el punto de entrada es Compartir, no "Abrir con": Chrome para Android no implementa la File Handling API, así que una PWA instalada no puede registrarse como handler de una extensión de archivo.
.md desde la app Archivos. Si te lo mandaron por WhatsApp o Telegram, primero hay que guardarlo con Guardar en Archivos.La app detecta iOS y se acomoda sola: esconde Abrir carpeta (webkitdirectory existe en WebKit pero el selector lo ignora), le saca el filtro accept al input de archivos (el selector de Archivos traduce las extensiones a tipos del sistema y termina mostrando todo en gris), deja Abrir archivos como acción principal y cambia las instrucciones de Android por las de arriba. La detección va por user agent a propósito: por capacidades, iOS declara esas APIs como presentes.
Acá instalarla pesa más que en otras plataformas: WebKit borra el IndexedDB y los cachés de un sitio común a los siete días sin visitas, y las apps agregadas a la pantalla de inicio quedan exentas.
.md desde fuera de la app. Ahí la única entrada es el selector Abrir archivos dentro de la propia app. Para integrarla de verdad haría falta una envoltura nativa (WKWebView más un document type declarado), cosa que este repo no hace..md son inconsistentes. Las apps de Android reportan los Markdown con varios tipos distintos, así que share_target acepta una lista amplia que incluye application/octet-stream. El costo es que el lector puede aparecer también al compartir otros archivos binarios.Almacenamiento interno/Android/media/com.whatsapp/WhatsApp/Media/WhatsApp Documents; ↻ Actualizar carpeta suma los que lleguen después.webkitdirectory), que le entrega a la página una foto de una sola vez y nunca la ruta de la carpeta. ↻ Actualizar carpeta vuelve a abrir ese selector, y Chrome pregunta cada vez si cargar todos los archivos de la carpeta, contando todo lo que hay adentro y no sólo los .md. Ninguna de las dos cosas se puede evitar desde la página..md llegan como PDF. Si Drive convirtió un .md subido en un documento de Google, lo entrega como PDF. El lector lo detecta y no lo abre; la solución es Archivo → Descargar → Markdown (.md) en Documentos.Las copias instaladas se actualizan solas sin interrumpir un documento abierto. El mecanismo tiene cuatro partes:
sw.js sirve el HTML network-first. Con conexión siempre gana la versión del servidor; el caché es solo respaldo offline. Esto es lo que evita que una versión vieja quede congelada para siempre en un dispositivo. Los íconos y el manifest usan stale-while-revalidate: instantáneos desde el caché, refrescados en segundo plano.Subí VERSION arriba de todo en sw.js y desplegá. La versión nueva se instala en segundo plano y queda en espera mientras la app esté en uso. Una vez activada, su handler de activate elimina automáticamente los cachés de shell lector-md-* anteriores:
const VERSION = "v0.10.0";
El caché del share target y el área de trabajo guardada en IndexedDB quedan fuera de esa limpieza. Por eso borrar un caché viejo de la aplicación nunca elimina un documento cargado. El área de trabajo sólo se vacía con Limpiar todo, quitando cada documento o borrando los datos del sitio desde el navegador.
├── index.html # La app completa: HTML + CSS + JS en un solo archivo
├── ejemplo.md # Documento de muestra en español: manual y demo viva de la sintaxis soportada
├── example.md # El mismo documento de muestra, en inglés
├── ejemplo-portada.png # Ilustración que encabeza ese documento
├── manifest.json # Metadata de PWA, file_handlers y share_target
├── sw.js # Service worker: caché, actualizaciones y share target
├── voz-worker.js # Voz mejorada opcional (Supertonic 2) en un Web Worker
├── icon-192.png # Ícono de la app (192×192)
├── icon-512.png # Ícono de la app (512×512)
├── icon-maskable-512.png # Variante "maskable" (Android puede recortarla en distintas formas)
└── vercel.json # Headers y el rewrite de /share-target para Vercel
index.html es completamente autocontenido: podés copiar únicamente ese archivo —a un pendrive, a un mail— y funciona en cualquier máquina con navegador. Los demás archivos solo hacen falta para el modo instalable e integrado al sistema.
Los archivos compartidos viajan por un caché. Android entrega el archivo compartido como un POST a /share-target, pero un hosting estático no puede recibir POST. El service worker lo intercepta, escribe el archivo en un caché de vida corta y redirige a la app, que lo levanta y vacía el caché. El manifest también declara title y text: apps como WhatsApp mandan un asunto y un epígrafe, y sin esos parámetros Chrome los convierte en un archivo falso ("texto compartido") que parece el documento. El texto sólo se abre como documento si no llegó ningún archivo y tiene pinta de contenido (varias líneas, o largo); un nombre de archivo suelto sólo muestra un aviso. A un archivo sin extensión .md se le agrega.
El área de trabajo abierta se guarda localmente en IndexedDB. El contenido Markdown nunca se envía al servidor. Sólo puede desaparecer si el usuario borra los datos del sitio o si el dispositivo elimina ese almacenamiento; la app solicita almacenamiento persistente cuando el navegador lo permite.
El ícono maskable tiene el contenido escalado al 80% y centrado, dentro de la "zona segura" que Android respeta al recortar los íconos en distintas formas (círculo, squircle, etc. según el fabricante).
Abrir un archivo desde el sistema reutiliza la ventana abierta. file_handlers declara "launch_type": "single-client", que solo agrupa en una misma ventana varios archivos abiertos juntos. Lo que evita que un segundo .md abra una segunda ventana es "launch_handler": {"client_mode": "focus-existing"}: la ventana que ya está abierta pasa al frente y recibe el archivo por launchQueue, y el lector lo muestra mientras los anteriores quedan en la lista. Solo en escritorio (Chrome y Edge 110 o superior); si no hay ninguna ventana abierta, o en un navegador que lo ignore, la app abre una ventana nueva. En Windows se recomienda instalarla desde Edge: así los .md muestran el ícono del lector.
KaTeX es opcional por diseño. Se carga desde CDN y la app degrada a una aproximación en Unicode si el pedido falla, así el lector nunca depende de una llamada de red para mostrar un documento.
Mermaid se carga a demanda. La librería pesa unos 3 MB, así que se pide recién cuando el documento abierto tiene algún diagrama; después el service worker la cachea y los diagramas siguen funcionando sin conexión. El parser deja el código fuente en un <pre> y el SVG lo reemplaza una vez dibujado, de modo que "sin librería" y "diagrama inválido" caen en el mismo respaldo, que ya es legible.
MIT — usalo, modificalo, y adaptalo a lo que necesites.
Charts are drawn by the reader, not by a library. Bars, lines, areas, points and slices are simple enough that a charting library would cost more than it gives, and an in-house renderer keeps the offline, single-file case working. The SVG takes its colors from CSS variables instead of baking them in, so switching theme or printing needs no redraw; it is redrawn only when the column width changes, because it is built at real pixel size to keep axis text legible on a phone. The series palette is a categorical order checked for color-vision deficiency and contrast against both surfaces; since three light-mode hues fall below 3:1, every chart ships a table view.
Read aloud speaks one short fragment at a time. speechSynthesis costs nothing and needs no network, but each engine misbehaves in its own way: Chrome's online voices cut off after about 15 seconds inside a single utterance, and Android implements pause() as a cancel. So every block is split into fragments of at most ~200 characters, spoken one after another; pause is a cancel and play repeats the current fragment, the same on every platform. A counter discards the events of fragments that were already cut, and a watchdog moves on if a mobile voice never reports it finished. The text comes from the rendered page, not from the source: lists, table rows (with their column names, or chained into one sentence when a header ends in "…") and image alt texts are read; formulas and charts are described (see the next note); code and diagrams become a short announcement. The language is guessed by counting common Spanish and English words, because the voice has to match the document. A voice with localService === false sends the text to a server, which would break the promise that nothing leaves the machine, so it only plays after a confirmation that is remembered per voice.
The enhanced voice is optional and runs on the device. It is Supertonic 2 (OpenRAIL-M), int8-quantized by sherpa-onnx and mirrored at mdreader-supertonic/supertonic-int8, on onnxruntime-web in WebAssembly, single-threaded (multithreading would need COOP/COEP headers on the whole site, which would break external images in documents). It only downloads (~100 MB) after the user picks one of its voices and taps play. voz-worker.js keeps the files in its own lector-md-voz cache, with no version suffix and URLs pinned to fixed revisions; the service worker lets those requests through and leaves that cache alone on update. The engine is imported from the cache through a blob URL, so it also works offline. The worker generates the fragment being played and the next two; the page plays them as WAV in an <audio> element, so speed is playbackRate (no regeneration), pause is a real pause, and Media Session shows lock-screen controls. If generation runs slower than 1.5× the chosen speed over the first ~10 seconds of audio, the denoising steps drop from 5 to 3. The model is released from memory 5 minutes after listening stops.
Formulas and charts are read from their source, with rules instead of AI. Once a formula is drawn its TeX is gone, so renderMath keeps it in a data-tex attribute. A small parser turns the TeX into a tree that doesn't depend on the language, and a second step says it with a per-language table (VOZ_HABLA): "x squared", "a over b", "integral from 0 to 1 of", "derivative of y with respect to x". A short exponent or subscript is said inline and a long one is closed with an end marker ("end of exponent"), so the listener knows where it stops. A letter followed by parentheses or square brackets is read as a function call ("u of t minus a", and "x of n" for the discrete-time x[n]); if the argument names the same letter, as in s(s+1), it stays a product. An uppercase letter is called "capital" only when its lowercase twin also appears in the document's formulas (H(s) next to h(t)). In an exponent, letters side by side are a product ("e to the j times t"). A + or − between two letters gets a short pause on each side, because voices run "a plus c" together; inside a fraction, root or exponent that pause is dropped. * is read as convolution, and a cases block with one equation per row as a system of equations. Whatever the parser can't make sense of falls back to the word formula. In prose, a slash is always said "slash" and a backslash "backslash", since every voice names them its own way. Boxes, colors and spacing commands are skipped and only what they wrap is read; \mathcal{L}\{…\} is "Laplace transform of", f^{(n)} is "derivative of order n of f", f' is "f prime", 0^- is "zero minus", and \underbrace{…}_{label} reads the expression and then its label. A block formula is read on its own, without announcing it first; the word "formula" is only used when the parser gives up. Surnames that Spanish voices mispronounce (Laplace, Fourier, Cauchy…) are swapped, only when speaking, for a respelling the voice gets right: the table is VOZ_NOMBRES, and it is easy to correct. A chart is described from the same parsed data it is drawn from: type, title and, per series, either its values (up to six) or first, last, maximum, minimum and trend; a scatter chart adds whether the points rise or fall along the horizontal axis, from their correlation. Nothing is sent anywhere, so it works offline. The spoken phrases live in VOZ_HABLA, apart from TEXTOS, because they follow the document's language and not the interface's.
Diagram text is excluded from the in-document search. A rendered diagram is SVG, and an HTML <mark> inside it would not paint — it would make the matched text disappear. Hidden nodes (the diagram source behind a drawn SVG) are skipped for the same reason: a hit that can't be shown shouldn't be counted.
The help document is embedded, not fetched. There is one <script type="text/markdown"> block per language, where the Markdown stays raw and readable without escaping the backticks of its own code fences. Fetching ejemplo.md instead would break the portable case: opened over file://, the browser refuses to read the file next to it, which is exactly the situation where built-in help matters most. The help is a separate view — it never enters the file list or IndexedDB, since that list means your files. ejemplo.md and example.md at the repo root are copies of those two blocks, kept for reading on GitHub. The embedded blocks are the source: edit them and copy the text over.
Every visible string lives in one table. TEXTOS holds a Spanish and an English entry per key. Static elements carry data-t, data-t-title, data-t-placeholder or data-t-aria, and a small script placed before the help blocks fills them in before the first paint. The language is the first entry of navigator.languages that is Spanish or English (English if none is) unless the user picked one. Only user-facing text is translated; code comments and identifiers stay in Spanish. A web app manifest can't vary by language, so the installed app keeps the name Lector MD and its Spanish description.
Diagrams are redrawn in light colors for printing. Mermaid bakes colors into the SVG, so a diagram rendered in dark theme would print as pale strokes on white paper. The ⎙ PDF button redraws them light, prints, and restores the screen theme. Pressing Ctrl+P directly bypasses this and prints with the current theme.
GitHub import reads the API, not a clone. It resolves the default branch when none is given, asks for the recursive tree of that one commit, filters it down to .md/.markdown/.txt, and downloads each file's content by blob SHA (Git's own content-addressed hash, listed in the tree response) rather than by branch name and path. raw.githubusercontent.com is a CDN that can lag a few minutes behind a fresh push for a given branch/path, no matter what the client does — a SHA-addressed request has no such window, since the hash only ever means one exact content. Every request also carries a timestamp and cache: "no-store", and the service worker leaves api.github.com uncached, so Actualizar always sees the real latest commit.
Images inside an imported .md are cached by content, not by URL. An <img> pointing at raw.githubusercontent.com is a fixed URL that never tells you whether the file behind it changed, so a naive cache would either serve stale images forever or re-fetch every one of them on every open. The reader avoids both: the recursive tree call it already makes for .md files also returns the blob SHA of every image in the repo, at no extra request cost, and the page hands that SHA map to the service worker through IndexedDB — the one store the origin's worker can read directly, without postMessage. On each image request the worker compares the known SHA against the one stored alongside its cached response; a match is served straight from that cache, and only a mismatch (or a first-time image) touches the network. This is the same content-addressing principle already used for .md blobs, just applied one layer further down, and it keeps raw.githubusercontent.com traffic proportional to what actually changed in the repo, not to how many times a document gets reopened:
sequenceDiagram
participant P as Page
participant API as api.github.com
participant SW as Service Worker
participant IDB as IndexedDB
P->>API: GET /git/trees/branch?recursive=1
API-->>P: full tree (SHAs for .md files and images)
P->>API: GET /git/blobs/{sha} for each .md
Note over P,API: this always happens, whether content changed or not
P->>IDB: store image SHAs
Note over SW,IDB: images have NOT been requested yet
Note over P: the browser repaints the <img>
P->>SW: fetch image (same SHA it already had cached)
SW->>IDB: look up known SHA for that URL -> same SHA
SW-->>P: serve from IMG_CACHE, no network request
.md desde WhatsApp, el explorador, Drive o Telegram directo al lector. En escritorio (Windows, macOS, Linux, ChromeOS) se registra como file handler y aparece en "Abrir con".@media print propia para que lo que se lee en pantalla se imprima limpio.index.html, así que está desde el primer arranque y también en una copia en pendrive sin conexión.Prism se carga a demanda y en modo manual. El paquete base más las siete gramáticas extra pesan unos 38 KB, y se piden sólo si el documento abierto tiene un bloque de código en un lenguaje soportado. Prism sólo marca los fragmentos con clases; los colores son variables CSS del propio lector, así el resaltado sigue el tema sin una hoja de estilos de terceros y se imprime en claro. Una vez resaltado, la búsqueda no encuentra un texto que cruce dos fragmentos de distinto color, igual que no lo encuentra entre negrita y texto normal.
Los gráficos los dibuja el lector, no una librería. Barras, líneas, áreas, puntos y porciones son lo bastante simples como para que una librería de gráficos cueste más de lo que aporta, y un dibujo propio mantiene funcionando el caso de un solo archivo sin conexión. El SVG toma los colores de variables CSS en vez de hornearlos, así que cambiar de tema o imprimir no obliga a redibujar; sólo se redibuja si cambia el ancho de la columna, porque se arma a tamaño real para que el texto de los ejes se lea en el celular. La paleta de series es un orden categórico verificado para daltonismo y contraste contra los dos fondos; como tres tonos del tema claro quedan por debajo de 3:1, cada gráfico trae una vista de tabla.
Escuchar dice un fragmento corto por vez. speechSynthesis no cuesta nada y no necesita red, pero cada motor falla a su manera: las voces en línea de Chrome se cortan a los ~15 segundos dentro de una misma locución, y Android implementa pause() como una cancelación. Por eso cada bloque se parte en fragmentos de hasta ~200 caracteres, que se dicen uno tras otro; pausar es cancelar y reproducir repite el fragmento en curso, igual en todas las plataformas. Un contador descarta los eventos de los fragmentos que ya se cortaron, y un vigía sigue adelante si una voz del celular nunca avisa que terminó. El texto sale de la página ya dibujada, no del código fuente: se leen las listas, las filas de las tablas (con el nombre de su columna, o encadenadas en una sola frase si un encabezado termina en «…») y el texto descriptivo de las imágenes; las fórmulas y los gráficos se describen (ver la nota siguiente); el código y los diagramas pasan a ser un aviso corto. El idioma se adivina contando palabras comunes del español y del inglés, porque la voz tiene que coincidir con el documento. Una voz con localService === false manda el texto a un servidor, y eso rompería la promesa de que nada sale de la máquina, así que sólo suena tras una confirmación que se recuerda por voz.
La voz mejorada es opcional y corre en el dispositivo. Es Supertonic 2 (OpenRAIL-M), cuantizado a int8 por sherpa-onnx y copiado en mdreader-supertonic/supertonic-int8, sobre onnxruntime-web en WebAssembly con un solo hilo (varios hilos exigirían cabeceras COOP/COEP en todo el sitio, que romperían las imágenes externas de los documentos). Se descarga (unos 100 MB) recién cuando el usuario elige una de sus voces y toca reproducir. voz-worker.js guarda los archivos en su propio caché, lector-md-voz, sin sufijo de versión y con URLs fijadas a revisiones; el service worker deja pasar esos pedidos y no toca ese caché al actualizarse. El motor se importa desde el caché con una URL blob, así también funciona sin conexión. El worker genera el fragmento que suena y los dos siguientes; la página los reproduce como WAV en un elemento <audio>, así la velocidad es playbackRate (sin regenerar), la pausa es real y Media Session muestra los controles en la pantalla bloqueada. Si en los primeros ~10 segundos de audio la generación va más lenta que 1,5 veces la velocidad elegida, las vueltas de refinado bajan de 5 a 3. El modelo se libera de la memoria 5 minutos después de dejar de escuchar.
Las fórmulas y los gráficos se leen desde su origen, con reglas y sin IA. Una vez dibujada una fórmula su TeX ya no está, así que renderMath lo guarda en un atributo data-tex. Un parser chico pasa el TeX a un árbol que no depende del idioma, y un segundo paso lo dice con una tabla por idioma (VOZ_HABLA): «x al cuadrado», «a sobre b», «integral desde 0 hasta 1 de», «derivada de y respecto de x». Un exponente o subíndice corto se dice de corrido y uno largo se cierra con un aviso («fin del exponente») para que se sepa dónde termina. Una letra seguida de paréntesis o de corchetes se lee como una llamada a función («u de t menos a», y «x de n» para el x[n] del tiempo discreto); si el argumento nombra la misma letra, como en s(s+1), sigue siendo un producto. Una mayúscula se aclara («mayúscula») sólo cuando su minúscula también aparece en las fórmulas del documento (H(s) junto a h(t)). En un exponente, las letras pegadas son un producto («e elevado a la j por t»). Un + o un − entre dos letras lleva una pausa corta de cada lado, porque las voces dicen «a más ce» de corrido; adentro de una fracción, una raíz o un exponente esa pausa se omite. El * se lee como convolución, y un bloque cases con una ecuación por fila como un sistema de ecuaciones. Lo que el parser no entiende cae a la palabra fórmula. En la prosa, la barra se dice siempre «barra» y la barra invertida «barra invertida», porque cada voz las nombra a su manera. Los recuadros, los colores y los espacios se saltean y sólo se lee lo que encierran; \mathcal{L}\{…\} es «transformada de Laplace de», f^{(n)} es «derivada de orden n de f», f' es «f prima», 0^- es «0 menos», y \underbrace{…}_{rótulo} lee la expresión y después su rótulo. Una fórmula de bloque se lee sola, sin anunciarla antes; la palabra «fórmula» queda para cuando el parser se rinde. Los apellidos que las voces en español pronuncian mal (Laplace, Fourier, Cauchy…) se cambian, sólo al hablar, por una escritura que la voz lee bien: la tabla es VOZ_NOMBRES, y es fácil de corregir. Un gráfico se describe con los mismos datos ya leídos con los que se dibuja: tipo, título y, por serie, sus valores (hasta seis) o primero, último, máximo, mínimo y tendencia; la dispersión suma si los puntos suben o bajan a lo largo del eje horizontal, según su correlación. No se manda nada a ningún lado, así que funciona sin conexión. Las frases dichas viven en VOZ_HABLA, aparte de TEXTOS, porque siguen el idioma del documento y no el de la interfaz.
El texto de los diagramas queda fuera de la búsqueda. Un diagrama dibujado es SVG, y un <mark> de HTML adentro no se pinta: haría desaparecer el texto encontrado. Lo oculto (el código fuente detrás de un SVG ya dibujado) se saltea por lo mismo: no tiene sentido contar un resultado que no se puede mostrar.
El documento de ayuda va embebido, no se baja. Hay un bloque <script type="text/markdown"> por idioma, donde el Markdown queda crudo y legible sin tener que escapar los acentos graves de sus propios bloques de código. Bajar ejemplo.md rompería el caso portátil: abierto con file:// el navegador no deja leer el archivo de al lado, que es justo la situación donde una ayuda incorporada más sirve. La ayuda es una vista aparte: nunca entra en la lista de archivos ni en IndexedDB, porque esa lista es de tus archivos. ejemplo.md y example.md, en la raíz, son copias de esos dos bloques para poder leerlos en GitHub. El original son los bloques embebidos: se editan ahí y se copia el texto.
Todos los textos visibles están en una tabla. TEXTOS tiene una entrada en español y otra en inglés por clave. Los elementos fijos llevan data-t, data-t-title, data-t-placeholder o data-t-aria, y un script chico ubicado antes de los bloques de ayuda los completa antes del primer cuadro. El idioma es el primero de navigator.languages que sea español o inglés (inglés si no hay ninguno), salvo que el usuario haya elegido otro. Sólo se traduce lo que ve el usuario; los comentarios y los nombres del código siguen en español. El manifest de una PWA no puede variar según el idioma, así que la app instalada conserva el nombre Lector MD y su descripción en español.
Los diagramas se redibujan en claro para imprimir. Mermaid hornea los colores dentro del SVG, así que un diagrama renderizado en tema oscuro saldría con trazos pálidos sobre papel blanco. El botón ⎙ PDF los redibuja en claro, imprime y restaura el tema de pantalla. Con Ctrl+P directo eso no se puede interceptar y sale con el tema actual.
La importación de GitHub lee la API, no clona nada. Resuelve la rama por defecto si no se indica ninguna, pide el árbol recursivo de ese commit puntual, lo filtra a .md/.markdown/.txt, y descarga el contenido de cada archivo por su SHA de blob (el hash de contenido propio de Git, que ya viene en la respuesta del árbol), no por rama+ruta. raw.githubusercontent.com es un CDN que puede tardar unos minutos en reflejar un push reciente para una rama/ruta dada, sin importar lo que haga el cliente; un pedido direccionado por SHA no tiene esa ventana, porque el hash sólo puede significar un contenido exacto. Cada pedido además lleva marca de tiempo y cache: "no-store", y el service worker deja api.github.com sin cachear, así que Actualizar siempre ve el último commit real.
Las imágenes de un .md importado se cachean por contenido, no por URL. Un <img> que apunta a raw.githubusercontent.com es una URL fija que nunca dice si el archivo detrás cambió, así que un caché ingenuo terminaría sirviendo imágenes viejas para siempre, o volviendo a pedir todas en cada apertura. El lector evita las dos cosas: el mismo pedido de árbol recursivo que ya hace para los .md también devuelve el SHA de blob de cada imagen del repo, sin ningún pedido extra, y la página le pasa ese mapa de SHAs al service worker a través de IndexedDB —el único almacén que el worker del origen puede leer directo, sin postMessage—. En cada pedido de imagen, el worker compara el SHA conocido contra el que quedó guardado junto a la respuesta cacheada: si coincide, la sirve directo de ese caché, y sólo un SHA distinto (o una imagen nunca vista) toca la red. Es el mismo principio de direccionamiento por contenido que ya se usa para los blobs de los .md, aplicado una capa más abajo, y mantiene el tráfico contra raw.githubusercontent.com proporcional a lo que realmente cambió en el repo, no a cuántas veces se reabre un documento:
sequenceDiagram
participant P as Página
participant API as api.github.com
participant SW as Service Worker
participant IDB as IndexedDB
P->>API: GET /git/trees/rama?recursive=1
API-->>P: árbol completo (SHAs de .md e imágenes)
P->>API: GET /git/blobs/{sha} por cada .md
Note over P,API: esto SIEMPRE pasa, cambie o no el contenido
P->>IDB: guardarShaImagenesGithub (nuevos SHAs de imagen)
Note over SW,IDB: las imágenes NO se pidieron todavía
Note over P: el navegador vuelve a pintar el <img>
P->>SW: fetch imagen (mismo SHA que ya tenía cacheado)
SW->>IDB: shaConocidoDe(url) -> mismo SHA
SW-->>P: responde desde IMG_CACHE, sin ir a la red