diff --git a/AGENT.md b/AGENT.md index 36a0b61..00a8a1e 100644 --- a/AGENT.md +++ b/AGENT.md @@ -31,7 +31,7 @@ Decisioni e motivazioni negli ADR ([docs/adr/](docs/adr/)); qui solo la mappa de - [Core/AppInfo.cs](src/agent-wiki-ui/Core/AppInfo.cs): nome dell'app, letto dall'`AssemblyName` del csproj (unica sorgente). - [Gui/MainViewModel.cs](src/agent-wiki-ui/Gui/MainViewModel.cs): stato e logica della chat (invio, apertura/eliminazione, salvataggio subito all'invio e di nuovo alla risposta, riapertura dell'ultima chat all'avvio, interruttore CLI: cambiare motore apre una nuova chat; aggiornamento dei CLI con un solo aggiornamento alla volta, bloccando invio e cambio motore). - [Gui/MainWindow.axaml](src/agent-wiki-ui/Gui/MainWindow.axaml) (+ `.cs`): layout XAML con binding compilati. [Gui/App.axaml](src/agent-wiki-ui/Gui/App.axaml): token di colore come risorse di tema chiaro/scuro e stili; specifiche in `docs/MVP.md` → "Design (UI/UX)". Cambiando l'aspetto aggiornare quella sezione. -- [Gui/MarkdownView.cs](src/agent-wiki-ui/Gui/MarkdownView.cs): renderer markdown proprio (albero Markdig → controlli Avalonia, `DisableHtml`); la parte sulle immagini è in [Gui/MarkdownView.Images.cs](src/agent-wiki-ui/Gui/MarkdownView.Images.cs) (`partial`). Le immagini si mostrano solo da file locale (ADR-0006), mai da URL; i percorsi relativi si cercano nella cartella dell'exe e poi nelle superiori (radice della wiki). +- [Gui/MarkdownView.cs](src/agent-wiki-ui/Gui/MarkdownView.cs): renderer markdown proprio (albero Markdig → controlli Avalonia, `DisableHtml`); la parte sulle immagini è in [Gui/MarkdownView.Images.cs](src/agent-wiki-ui/Gui/MarkdownView.Images.cs) (`partial`); le formule LaTeX (ADR-0008) sono in [Gui/MarkdownView.Math.cs](src/agent-wiki-ui/Gui/MarkdownView.Math.cs) (`partial`, libreria `Sylinko.CSharpMath.Avalonia`). Le immagini si mostrano solo da file locale (ADR-0006), mai da URL; i percorsi relativi si cercano nella cartella dell'exe e poi nelle superiori (radice della wiki). - Dipendenze NuGet: Avalonia (+ Desktop, Themes.Fluent), Markdig. ## Vincoli non ovvi diff --git a/README.md b/README.md index 7a86304..cb87637 100644 --- a/README.md +++ b/README.md @@ -61,19 +61,11 @@ Verificato: da Linux/WSL entrambi gli eseguibili; da Windows nativo (PowerShell - Al primo avvio le librerie native (Skia, HarfBuzz) vengono estratte in una cartella temporanea: il primo avvio è un po' più lento. - I motori (`claude`, `codex`) devono essere installati e nel PATH dello stesso sistema: vedi [docs/INSTALL.md](docs/INSTALL.md). -## Immagini nelle risposte (wiki con catalogo di figure) -Non esiste (ancora) uno standard per cartella e catalogo delle immagini: la struttura qui sotto è solo un **esempio** e può cambiare. L'app non la conosce: mostra il file indicato dal percorso scritto nella risposta, ovunque stia. +## Formule nelle risposte +Le formule LaTeX si vedono composte: `$ ... $` nel testo e `$$ ... $$` a blocco ([ADR-0008](docs/adr/0008-formule-latex-nel-markdown.md)). Se il LaTeX non è valido compare il sorgente come testo; i `$` isolati (per esempio «10 $ e 20 $») restano testo. Le formule non sono selezionabili come testo, ma il pulsante **Copia** della risposta copia il markdown con il sorgente. -Se la risposta contiene `![didascalia](percorso)` e il file esiste, la chat mostra l'immagine; altrimenti compare il testo `[didascalia]`. Gli URL `http(s)` non si scaricano. Un percorso relativo si cerca nella cartella dell'eseguibile e poi in quelle superiori, quindi funziona con una **wiki** (cartella di pagine markdown con un indice, un catalogo delle figure e le immagini) anche se l'eseguibile sta in una sottocartella dedicata alle sole query: -``` -/ radice del progetto, con CLAUDE.md (schema completo della wiki) - index.md indice delle pagine, con gli id delle figure di ogni pagina - .json catalogo: metadati di ogni figura (file, didascalia, posizione, avvertenze) - / le immagini - query/CLAUDE.md istruzioni solo-query (sola lettura, instradamento per indici, regole per le figure) - query/agent-wiki-ui l'eseguibile, da avviare da qui: il motore legge sia query/CLAUDE.md sia quello della radice -``` -Il catalogo lo legge il motore, non l'app: il `CLAUDE.md` chiede di trovare la figura nel catalogo e di citarla con un percorso relativo alla radice (`![Figura 1](/)`), con sotto la didascalia, la posizione e il link alla nota della figura. L'app mostra l'immagine e lascia la didascalia come testo markdown. Dettagli: [ADR-0006](docs/adr/0006-immagini-nel-markdown.md). Per preparare una wiki alle query (indice con la mappa delle figure, catalogo, istruzioni solo-query) c'è una convenzione, [ADR-0007](docs/adr/0007-schema-wiki-per-query-con-immagini.md),. +## Immagini nelle risposte +L'app mostra il file indicato dal percorso scritto nella risposta, ovunque stia; non conosce né cartelle né cataloghi di immagini. Se la risposta contiene `![didascalia](percorso)` e il file esiste, la chat mostra l'immagine; altrimenti compare il testo `[didascalia]`. Gli URL `http(s)` non si scaricano. Un percorso relativo si cerca nella cartella dell'eseguibile e poi in quelle superiori, quindi funziona anche se l'eseguibile sta in una sottocartella del progetto. Dettagli: [ADR-0006](docs/adr/0006-immagini-nel-markdown.md); per una convenzione di progetto interrogabile con immagini vedi [ADR-0007](docs/adr/0007-schema-wiki-per-query-con-immagini.md). ## Piattaforme Windows e Linux con lo stesso codice ([ADR-0001](docs/adr/0001-gui-multipiattaforma-avalonia.md)). Motori supportati: Claude Code e Codex CLI, già installati da terminale; Antigravity CLI è pianificato: [ADR-0002](docs/adr/0002-motori-cli-claude-codex-antigravity.md). diff --git a/docs/MVP.md b/docs/MVP.md index 901ae86..f648f7e 100644 --- a/docs/MVP.md +++ b/docs/MVP.md @@ -21,7 +21,7 @@ Senza argomenti si apre una finestra Avalonia (C# + XAML, nessun HTML) in stile **Struttura della finestra** - **Barra laterale** (sinistra, comprimibile con il pulsante a tre linee): logo e nome dell'app, pulsante **Nuova chat**, elenco delle chat salvate (titolo = inizio del primo messaggio, data/ora dell'ultimo scambio; la chat aperta è evidenziata). Un clic su una chat la riapre; la ✕ (con conferma) la elimina. Elenco vuoto: "Nessuna chat salvata". -- **Conversazione**: messaggi alternati utente/Claude. Le risposte sono renderizzate in markdown: titoli, liste, tabelle, citazioni, blocchi di codice, grassetto/corsivo/barrato, codice inline, link, immagini. Le immagini `![alt](percorso)` si mostrano se il percorso è locale (assoluto, `file://` o relativo: cercato nella cartella dell'exe e poi nelle cartelle superiori, cioè nella radice del progetto del motore) e il file è un'immagine leggibile entro i limiti (caricata in background: prima compare `[alt]`, poi l'immagine); altrimenti, e sempre per gli URL `http(s)`, compare il testo alternativo `[alt]` e non si scarica nulla ([ADR-0006](adr/0006-immagini-nel-markdown.md)). Il rendering è un renderer proprio ([MarkdownView.cs](../src/agent-wiki-ui/Gui/MarkdownView.cs)) che trasforma l'albero di Markdig in controlli Avalonia; l'HTML grezzo è disabilitato. Il testo è selezionabile. Sulle risposte il pulsante **Copia** copia il markdown; sui blocchi di codice copia il codice. +- **Conversazione**: messaggi alternati utente/Claude. Le risposte sono renderizzate in markdown: titoli, liste, tabelle, citazioni, blocchi di codice, grassetto/corsivo/barrato, codice inline, link, immagini, formule LaTeX (`$…$` nel testo, `$$…$$` a blocco; [ADR-0008](adr/0008-formule-latex-nel-markdown.md); se il LaTeX non è valido compare il sorgente). Le immagini `![alt](percorso)` si mostrano se il percorso è locale (assoluto, `file://` o relativo: cercato nella cartella dell'exe e poi nelle cartelle superiori, cioè nella radice del progetto del motore) e il file è un'immagine leggibile entro i limiti (caricata in background: prima compare `[alt]`, poi l'immagine); altrimenti, e sempre per gli URL `http(s)`, compare il testo alternativo `[alt]` e non si scarica nulla ([ADR-0006](adr/0006-immagini-nel-markdown.md)). Il rendering è un renderer proprio ([MarkdownView.cs](../src/agent-wiki-ui/Gui/MarkdownView.cs)) che trasforma l'albero di Markdig in controlli Avalonia; l'HTML grezzo è disabilitato. Il testo è selezionabile. Sulle risposte il pulsante **Copia** copia il markdown; sui blocchi di codice copia il codice. - **Schermata iniziale** (chat vuota): solo logo, titolo e sottotitolo (nessun suggerimento precompilato). - **Composer** (in basso, scheda arrotondata): campo di testo che cresce fino a ~8 righe e pulsante Invia circolare con icona. `Invio` invia, `Maiusc+Invio` va a capo. Hint sulla tastiera sotto la scheda. Non contiene più i selettori di modello ed effort. - **Impostazioni** (in alto a destra, pulsante con icona a cursori, simmetrico a quello della barra laterale): aprono un riquadro a comparsa con l'interruttore **CLI** (Claude Code, Codex; un motore non installato è mostrato "non installato" e non è selezionabile; disabilitato durante l'attesa), poi **Modello** (Predefinito, Fable 5.1, Opus 5.5, Sonnet 5.5, Haiku 5.5, "Altro ID…" con campo libero) ed **Effort** (Predefinito, low, medium, high, xhigh, max). **Valori iniziali**: il modello più piccolo del motore (Claude Code: Haiku 5.5; Codex: GPT-5.6 Luna) ed effort `low`, per risposte rapide e poco costose; sono quelli che si ritrovano all'avvio e a ogni cambio di CLI. Si chiude con `Esc` o con un clic fuori; le scelte restano valide per i messaggi successivi (non sono ancora salvate su disco). Passati come `--model` / `--effort`; con "Predefinito" il flag viene omesso. Solo GUI. @@ -46,7 +46,7 @@ Sorgente di verità per l'aspetto, derivato dalla skill `ui-ux-pro-max` (stile " ## Scelte tecniche Le scelte architetturali sono negli [ADR](README.md#indice-adr). Sintesi: -- .NET 10, un solo progetto `net10.0` per Windows e Linux. Avalonia (GUI), Markdig (parsing markdown). Il codice è diviso in `Core/` (avvio di `claude`, cronologia, nome app) e `Gui/` (finestra, ViewModel, renderer markdown); `Program.cs` sceglie CLI o GUI. +- .NET 10, un solo progetto `net10.0` per Windows e Linux. Avalonia (GUI), Markdig (parsing markdown), CSharpMath (formule LaTeX). Il codice è diviso in `Core/` (avvio di `claude`, cronologia, nome app) e `Gui/` (finestra, ViewModel, renderer markdown); `Program.cs` sceglie CLI o GUI. - `ProcessStartInfo.ArgumentList` per passare il prompt (niente problemi di quoting). - `claude` è un eseguibile nativo (`~/.local/bin`): si avvia direttamente, senza `cmd /c`. Lo stdin è chiuso subito per evitare l'attesa di 3s di claude. - Working dir: `AppContext.BaseDirectory`, non la cwd del chiamante. diff --git a/docs/PROJECT_STATE.md b/docs/PROJECT_STATE.md index 0106aa2..764ae62 100644 --- a/docs/PROJECT_STATE.md +++ b/docs/PROJECT_STATE.md @@ -44,6 +44,7 @@ Da [MVP.md](MVP.md). Restano non spuntati finché non hanno test automatici (reg | [0005](adr/0005-windows-senza-console.md) Windows senza console | Proposta | | [0006](adr/0006-immagini-nel-markdown.md) Immagini nel markdown (solo file locali) | Proposta (implementata, test verdi) | | [0007](adr/0007-schema-wiki-per-query-con-immagini.md) Schema di una wiki interrogabile, con immagini | Proposta (solo documentazione; nessun codice) | +| [0008](adr/0008-formule-latex-nel-markdown.md) Formule LaTeX nel markdown | Proposta (implementata) | ## Per chiudere l'MVP Proposta, derivata dai punti aperti sopra; da confermare. diff --git a/docs/README.md b/docs/README.md index d796dd8..46e8562 100644 --- a/docs/README.md +++ b/docs/README.md @@ -54,3 +54,4 @@ Non si scrive nulla nei puntatori: ogni modifica va in AGENT.md. Per un altro st | [0005](adr/0005-windows-senza-console.md) | Windows: eseguibile senza finestra di console | Proposta | | [0006](adr/0006-immagini-nel-markdown.md) | Immagini nel markdown: solo file locali | Proposta | | [0007](adr/0007-schema-wiki-per-query-con-immagini.md) | Schema di una wiki interrogabile, con immagini | Proposta | +| [0008](adr/0008-formule-latex-nel-markdown.md) | Formule LaTeX nel markdown | Proposta | diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 9b5b989..816c35b 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -24,6 +24,7 @@ Dettagli dell'MVP in [MVP.md](MVP.md). Stadio attuale e debiti: [PROJECT_STATE.m - [ ] GUI: streaming della risposta (`--output-format stream-json`), stop, rinomina/ricerca chat - [x] GUI: immagini nel markdown da percorso locale, con ripiego sul testo alternativo ([ADR-0006](adr/0006-immagini-nel-markdown.md); test MD-14, MD-19…MD-30); immagini remote rimandate - [x] Schema di una wiki interrogabile con immagini: convenzione per le istruzioni solo-query, l'indice e il catalogo ([ADR-0007](adr/0007-schema-wiki-per-query-con-immagini.md)); da provare con la chat reale su una wiki di esempio +- [x] GUI: formule LaTeX nel markdown, inline `$…$` e a blocco `$$…$$`, con ripiego sul sorgente ([ADR-0008](adr/0008-formule-latex-nel-markdown.md); test MD-31…MD-38) - [ ] Config file accanto all'exe - [ ] Test automatici e CI (vedi Fase 6) diff --git a/docs/TESTING.md b/docs/TESTING.md index 82e0293..55954cd 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -187,9 +187,17 @@ Si provano con `FakeClaude`/`FakeCodex` che rispondono a `--version` e `update`; | MD-25 | Immagine dentro un link, in una lista o in una tabella ⇒ resa senza rompere intervalli dei link (MD-13) né il layout | R | `Immagine_in_contesti_annidati` | [x] | | MD-26 | Cambio di tema con immagini presenti ⇒ vista ricostruita, immagini ancora mostrate | R | `Immagine_cambio_tema` | [x] | | MD-27 | Il caricamento è in background: subito dopo il rendering c'è il segnaposto `[alt]` e un'immagine in attesa; a fine decodifica compare l'immagine; un file corrotto lascia `[alt]` | R | `Immagine_caricamento_in_background` | [x] | -| MD-28 | Percorso relativo non presente nella cartella dell'exe ma in una cartella superiore (radice del progetto, es. `figure/` con l'exe in una sottocartella) ⇒ immagine mostrata | R | `Immagine_percorso_relativo_alla_radice` | [x] | +| MD-28 | Percorso relativo non presente nella cartella dell'exe ma in una cartella superiore (radice del progetto, es. `/` con l'exe in una sottocartella) ⇒ immagine mostrata | R | `Immagine_percorso_relativo_alla_radice` | [x] | | MD-29 | Se il file esiste in più cartelle, vince la più vicina all'exe; se non esiste in nessuna ⇒ testo alternativo | R | `Immagine_cartella_piu_vicina` | [x] | -| MD-30 | Struttura tipo wiki (catalogo JSON + cartella immagini + risposta con immagine, didascalia e link `[[fig-NNN]]` sotto): l'immagine compare e la didascalia resta testo; l'app non legge il JSON | R | `Immagine_risposta_stile_wiki` | [x] | +| MD-30 | Risposta con immagine e, sotto, didascalia e link in markdown: l'immagine compare e la didascalia resta testo; l'app non legge nessun catalogo | R | `Immagine_risposta_con_didascalia` | [x] | +| MD-31 | Formula inline `$…$` ⇒ controllo formula inserito nel testo; il testo prima e dopo resta intatto e non sparisce più (ADR-0008) | R | `Formula_inline` | [x] | +| MD-32 | Formula a blocco `$$…$$` ⇒ controllo formula (non un blocco di codice col sorgente) | R | `Formula_a_blocco` | [x] | +| MD-33 | LaTeX non valido (inline e a blocco) ⇒ sorgente come testo, nessuna eccezione | R | `Formula_non_valida_ripiego` | [x] | +| MD-34 | Costrutti LaTeX comuni (frazioni, apici e pedici, `\mathbf`, `\overline`, `\times`, lettere greche, `\left…\right`, `\sum`) ⇒ nessun errore | R | `Costrutti_latex_comuni` | [x] | +| MD-35 | Cambio di tema con formule presenti ⇒ vista ricostruita, formule ancora mostrate | R | `Formula_cambio_tema` | [x] | +| MD-36 | Formula dentro una lista, una tabella o un link ⇒ resa senza rompere il layout né gli intervalli dei link (MD-13) | R | `Formula_in_contesti_annidati` | [x] | +| MD-37 | Il nome accessibile della formula è il suo sorgente LaTeX | R | `Formula_nome_accessibile` | [x] | +| MD-38 | Simboli `$` isolati (es. prezzi «10 $ e 20 $») restano testo, nessuna formula | R | `Dollari_isolati_restano_testo` | [x] | | MD-15 | HTML grezzo disabilitato: i tag compaiono come testo, non come elementi | R | `Html_disabilitato` | [ ] | | MD-16 | Cambio di tema (chiaro/scuro): i colori si aggiornano ricostruendo la vista | R | `Cambio_tema` | [ ] | | MD-17 | Markdown malformato o blocchi sconosciuti non fanno crashare | R | `Markdown_malformato` | [ ] | diff --git a/docs/adr/0008-formule-latex-nel-markdown.md b/docs/adr/0008-formule-latex-nel-markdown.md new file mode 100644 index 0000000..71ab44d --- /dev/null +++ b/docs/adr/0008-formule-latex-nel-markdown.md @@ -0,0 +1,45 @@ +# ADR-0008 — Formule LaTeX nel markdown + +- **Stato**: Proposta +- **Data**: 2026-10-20 +- **Collegato a**: [ADR-0006](0006-immagini-nel-markdown.md) (stesso renderer, stessi vincoli di sicurezza), [ADR-0007](0007-schema-wiki-per-query-con-immagini.md) (stesso tipo di risposte) + +## Contesto +Le risposte dei motori possono contenere formule in LaTeX: `$ ... $` nel testo e `$$ ... $$` a blocco. Il renderer markdown ([Gui/MarkdownView.cs](../../src/agent-wiki-ui/Gui/MarkdownView.cs)) usa Markdig con `UseAdvancedExtensions()`, che comprende l'estensione matematica: il parser riconosce le formule ma il renderer non le gestisce. Quelle a blocco finiscono in un blocco di codice con il sorgente LaTeX; quelle nel testo non vengono disegnate (nodo ignorato). L'utente vede sorgente illeggibile o formule mancanti. + +## Criteri di decisione +1. Mostrare le formule composte (frazioni, apici e pedici, lettere greche, sommatorie, sopralineature) come in un testo tipografico. +2. Nessuna rete e nessuna esecuzione di codice: la formula è solo dato da disegnare (come per le immagini, ADR-0006). +3. Degrado sicuro: un LaTeX non valido mostra il sorgente come testo, mai un errore né un blocco della chat. +4. Coerenza con il resto del renderer: colori dal tema, ricostruzione al cambio di tema, testo circostante selezionabile, accessibilità. +5. Funziona su Windows e Linux nel publish single-file, senza runtime aggiuntivi. +6. Dipendenza manutenuta e compatibile con la versione di Avalonia in uso. + +## Opzioni considerate +| Opzione | Esito | Motivo | +|---|---|---| +| Lasciare com'è | Scartata | Il sorgente LaTeX o l'assenza delle formule rendono illeggibili le risposte con formule. | +| Convertire il LaTeX in testo Unicode (`x_1` → `x`+pedice) con un mini-interprete proprio | Scartata | Copre solo i casi semplici; frazioni, `\overline`, `\left…\right` e matrici richiederebbero di riscrivere un motore di composizione. | +| Rendere con un motore web (KaTeX/MathJax in una WebView) | Scartata | Aggiunge un browser incorporato e codice JavaScript, contro il criterio 2 e il peso dell'eseguibile. | +| **Libreria di composizione TeX per Avalonia (`Sylinko.CSharpMath.Avalonia`, licenza MIT, basata su CSharpMath)** | **Scelta** | Disegna LaTeX come controllo Avalonia, senza rete né script; l'ultima versione (12.0.0) è per Avalonia 12, come l'app. | + +## Decisione +- La formula tra `$ … $` è un'**immagine inline** composta dal controllo della libreria, inserita nel testo (`InlineUIContainer`) con allineamento al testo; `$$ … $$` è un **blocco** a sé, allineato a sinistra, con scorrimento orizzontale se più larga della colonna. +- Il colore è quello del testo del tema (`Fg`, o il colore ereditato se il token manca); al cambio di tema la vista si ricostruisce (già così per il resto). +- **Ripiego**: se il LaTeX non è valido la formula compare come testo sorgente (`$…$` con carattere monospazio), senza eccezioni. +- Il nome accessibile del controllo è il sorgente LaTeX; la formula non è selezionabile come testo (è un disegno): il pulsante **Copia** della risposta continua a copiare il markdown originale, quindi anche il sorgente delle formule. +- I simboli `$` isolati, ad esempio un prezzo («10 $ e 20 $»), restano testo. Markdig da solo li prenderebbe per una formula (e toglie gli spazi dal contenuto): il renderer applica la regola di Pandoc sul testo originale, cioè niente spazio subito dopo il `$` di apertura né subito prima di quello di chiusura. Per questo gli intervalli di Markdig si leggono su un testo con fine riga uniformi (`\n`). +- Nessuna rete e nessuna lettura di file: la formula è solo dato per il disegno. + +## Conseguenze +**Positive**: le risposte con formule mostrano le formule composte; nessun cambiamento ai motori né ai prompt. +**Negative**: una dipendenza in più (e le sue librerie di disegno); il pacchetto è un fork per Avalonia 12 e va tenuto d'occhio quando Avalonia aggiorna; le formule non sono selezionabili come testo; il supporto non copre tutto LaTeX (le macro non supportate cadono nel ripiego). + +## Da verificare +- Dimensione dell'eseguibile pubblicato (single-file) dopo l'aggiunta della libreria e dei suoi font. +- Allineamento verticale delle formule inline rispetto al testo con font diversi, su Windows e Linux. +- Quali comandi LaTeX comuni (matrici, ambienti `align`, estensioni a pacchetto) sono supportati. +- Il comportamento con formule molto lunghe in una riga di testo (a capo). + +## Riferimenti +- [MVP.md](../MVP.md) → "GUI (chat)"; matrice MD-31…MD-38 in [TESTING.md](../TESTING.md). diff --git a/src/agent-wiki-ui/Gui/MarkdownView.Math.cs b/src/agent-wiki-ui/Gui/MarkdownView.Math.cs new file mode 100644 index 0000000..e615197 --- /dev/null +++ b/src/agent-wiki-ui/Gui/MarkdownView.Math.cs @@ -0,0 +1,72 @@ +using Avalonia.Automation; +using Avalonia.Controls; +using Avalonia.Controls.Documents; +using Avalonia.Controls.Primitives; +using Avalonia.Layout; +using Avalonia.Media; +using CSharpMath.Avalonia; +using Markdig.Extensions.Mathematics; +using Markdig.Syntax; + +namespace AgentWikiUi.Gui; + +/// Formule LaTeX nel markdown (ADR-0008): inline $…$ e a blocco $$…$$; se il LaTeX non è valido compare il sorgente. +partial class MarkdownView +{ + const double InlineFormulaSize = 15; + const double BlockFormulaSize = 18; + + /// Sorgente LaTeX di un blocco $$…$$ (righe tra i delimitatori). + static string BlockSource(LeafBlock block) => + string.Join('\n', block.Lines.Lines.Take(block.Lines.Count).Select(l => l.ToString())).Trim(); + + Control FormulaBlock(string latex) + { + var formula = Formula(latex, BlockFormulaSize, "$$"); + // formula più larga della colonna: scorrimento orizzontale invece di tagliarla + return new ScrollViewer + { + HorizontalScrollBarVisibility = ScrollBarVisibility.Auto, VerticalScrollBarVisibility = ScrollBarVisibility.Disabled, + HorizontalAlignment = HorizontalAlignment.Left, Content = formula, + }; + } + + // colore del testo del tema (token Fg); senza il token (es. nei test) quello ereditato dal controllo + Color TextColor() => + this.TryFindResource("Fg", ActualThemeVariant, out var v) && v is ISolidColorBrush s ? s.Color + : TextElement.GetForeground(this) is ISolidColorBrush f ? f.Color : Colors.Black; + + /// Regola di Pandoc per $…$: niente spazio dopo l'apertura né prima della chiusura, altrimenti i due $ sono testo (prezzi, importi). Markdig da solo accetterebbe «10 $ e 20 $». + static bool IsDollarText(string source, MathInline math) + { + // Markdig toglie gli spazi dal contenuto: si controlla il testo originale tra i due delimitatori + if (math.DelimiterCount != 1) return false; + var (start, end) = (math.Span.Start, math.Span.End); + if (start < 0 || end >= source.Length || end - start < 2) return false; // intervallo non affidabile: si tratta come formula + return char.IsWhiteSpace(source[start + 1]) || char.IsWhiteSpace(source[end - 1]); + } + + Control FormulaInline(string latex) => Formula(latex, InlineFormulaSize, "$"); + + /// Controllo che disegna la formula; se il LaTeX non è valido, il sorgente come testo monospazio (ripiego sicuro). + Control Formula(string latex, double size, string delimiter) + { + latex = latex.Trim(); + try + { + var view = new MathView + { + LaTeX = latex, FontSize = (float)size, TextColor = TextColor(), DisplayErrorInline = false, + VerticalAlignment = VerticalAlignment.Center, HorizontalAlignment = HorizontalAlignment.Left, + }; + if (latex.Length > 0 && string.IsNullOrEmpty(view.ErrorMessage)) + { + AutomationProperties.SetName(view, latex); + ToolTip.SetTip(view, latex); + return view; + } + } + catch (Exception) { /* LaTeX o disegno non riusciti: ripiego sul sorgente */ } + return new TextBlock { Text = delimiter + latex + delimiter, FontFamily = Mono, FontSize = 13.5, Foreground = Brush("FgMuted"), TextWrapping = TextWrapping.Wrap }; + } +} diff --git a/src/agent-wiki-ui/Gui/MarkdownView.cs b/src/agent-wiki-ui/Gui/MarkdownView.cs index f8e4b23..65ac6fa 100644 --- a/src/agent-wiki-ui/Gui/MarkdownView.cs +++ b/src/agent-wiki-ui/Gui/MarkdownView.cs @@ -6,6 +6,7 @@ using Avalonia.Input.Platform; using Avalonia.Layout; using Avalonia.Media; using Markdig; +using Markdig.Extensions.Mathematics; using Markdig.Extensions.Tables; using Markdig.Syntax; using Markdig.Syntax.Inlines; @@ -33,7 +34,14 @@ partial class MarkdownView : Decorator if (change.Property == SourceProperty) Rebuild(); } - void Rebuild() => Child = string.IsNullOrEmpty(Source) ? null : RenderBlocks(Markdig.Markdown.Parse(Source, Pipeline), 10); + string parsed = ""; + + void Rebuild() + { + // fine riga uniformi: gli intervalli di Markdig (Span) devono valere per il testo che si legge qui (ADR-0008) + parsed = Source?.ReplaceLineEndings("\n") ?? ""; + Child = parsed.Length == 0 ? null : RenderBlocks(Markdig.Markdown.Parse(parsed, Pipeline), 10); + } IBrush Brush(string key) => this.TryFindResource(key, ActualThemeVariant, out var v) && v is IBrush b ? b : Brushes.Gray; @@ -48,6 +56,7 @@ partial class MarkdownView : Decorator { HeadingBlock h => Text(h.Inline, bold: true, size: h.Level switch { 1 => 24, 2 => 20, 3 => 18, 4 => 16, _ => 15.5 }), ParagraphBlock p => ImageParagraph(p) ?? Text(p.Inline), + MathBlock m => FormulaBlock(BlockSource(m)), FencedCodeBlock or CodeBlock => Code(string.Join('\n', ((LeafBlock)block).Lines.Lines.Take(((LeafBlock)block).Lines.Count).Select(l => l.ToString()))), QuoteBlock q => new Border { BorderBrush = Brush("Border"), BorderThickness = new(3, 0, 0, 0), Padding = new(12, 0, 0, 0), Child = RenderBlocks(q, 8) }, ListBlock l => List(l), @@ -145,6 +154,16 @@ partial class MarkdownView : Decorator switch (node) { case LiteralInline lit: Add(target, lit.Content.ToString(), st, ref pos); break; + case MathInline math: + var latex = math.Content.ToString(); + if (IsDollarText(parsed, math)) // es. «10 $ e 20 $»: non è una formula + Add(target, parsed.Substring(math.Span.Start, math.Span.End - math.Span.Start + 1), st, ref pos); + else + { + target.Add(new InlineUIContainer(FormulaInline(latex))); + pos += 1; // un controllo incorporato occupa una posizione nel testo + } + break; case LineBreakInline br: Add(target, br.IsHard ? "\n" : " ", st, ref pos); break; case CodeInline code: target.Add(new Run(code.Content) { FontFamily = Mono, FontSize = 13.5, Background = Brush("Muted") }); diff --git a/src/agent-wiki-ui/agent-wiki-ui.csproj b/src/agent-wiki-ui/agent-wiki-ui.csproj index ff14bdc..aeed520 100644 --- a/src/agent-wiki-ui/agent-wiki-ui.csproj +++ b/src/agent-wiki-ui/agent-wiki-ui.csproj @@ -25,6 +25,7 @@ + diff --git a/tests/agent-wiki-ui.Tests/Gui/MarkdownImageTests.cs b/tests/agent-wiki-ui.Tests/Gui/MarkdownImageTests.cs index b3abfb6..3b3ee92 100644 --- a/tests/agent-wiki-ui.Tests/Gui/MarkdownImageTests.cs +++ b/tests/agent-wiki-ui.Tests/Gui/MarkdownImageTests.cs @@ -246,7 +246,7 @@ public sealed class MarkdownImageTests : IDisposable } [AvaloniaFact] - public void Immagine_risposta_stile_wiki() + public void Immagine_risposta_con_didascalia() { var root = Wiki("wiki-c", "schema-a.png"); var risposta = """ diff --git a/tests/agent-wiki-ui.Tests/Gui/MarkdownMathTests.cs b/tests/agent-wiki-ui.Tests/Gui/MarkdownMathTests.cs new file mode 100644 index 0000000..8575957 --- /dev/null +++ b/tests/agent-wiki-ui.Tests/Gui/MarkdownMathTests.cs @@ -0,0 +1,138 @@ +using Avalonia; +using Avalonia.Automation; +using Avalonia.Controls; +using Avalonia.Controls.Documents; +using Avalonia.Headless.XUnit; +using Avalonia.Styling; +using Avalonia.Threading; +using Avalonia.VisualTree; +using CSharpMath.Avalonia; +using AgentWikiUi.Gui; + +namespace AgentWikiUi.Tests; + +/// Formule LaTeX nel markdown (ADR-0008): matrice MD-31…MD-38 in docs/TESTING.md. +[Trait("Category", "Gui")] +public sealed class MarkdownMathTests +{ + static (Window Window, MarkdownView View) Show(string markdown, double width = 600) + { + var view = new MarkdownView(); + var window = new Window { Width = width, Height = 800, Content = view }; + window.Show(); + view.Source = markdown; + Dispatcher.UIThread.RunJobs(); + window.UpdateLayout(); + return (window, view); + } + + static List Formulas(Visual v) => v.GetVisualDescendants().OfType().ToList(); + + static string Text(Visual v) => string.Concat(v.GetVisualDescendants().OfType() + .Select(t => t.Inlines is { Count: > 0 } ? string.Concat(t.Inlines.OfType().Select(r => r.Text)) : t.Text)); + + [AvaloniaFact] + public void Formula_inline() + { + var (_, view) = Show("Il gap è $E_g = E_{LUMO} - E_{HOMO}$ in eV."); + var f = Assert.Single(Formulas(view)); + Assert.Equal("E_g = E_{LUMO} - E_{HOMO}", AutomationProperties.GetName(f)); + Assert.True(string.IsNullOrEmpty(f.ErrorMessage)); + Assert.True(f.Bounds.Width > 0 && f.Bounds.Height > 0); + var text = Text(view); + Assert.Contains("Il gap è", text); + Assert.Contains("in eV.", text); + Assert.DoesNotContain("$", text); + } + + [AvaloniaFact] + public void Formula_a_blocco() + { + var (_, view) = Show("Prima.\n\n$$\n\\frac{a}{b} = c\n$$\n\nDopo."); + var f = Assert.Single(Formulas(view)); + Assert.Equal("\\frac{a}{b} = c", AutomationProperties.GetName(f)); + Assert.True(string.IsNullOrEmpty(f.ErrorMessage)); + Assert.True(f.Bounds.Height > 0); + Assert.DoesNotContain("\\frac", Text(view)); // non è un blocco di codice col sorgente + Assert.Contains("Prima.", Text(view)); + Assert.Contains("Dopo.", Text(view)); + } + + [AvaloniaTheory] + [InlineData("Testo $\\frac{a$ fine")] + [InlineData("$$\n\\unknowncommand{x}\n$$")] + [InlineData("Testo $\\begin{matrix$ fine")] + public void Formula_non_valida_ripiego(string markdown) + { + var (_, view) = Show(markdown); + Assert.Empty(Formulas(view)); + Assert.Contains("$", Text(view)); // il sorgente resta visibile come testo + } + + [AvaloniaTheory] + [InlineData("\\frac{a}{b}")] + [InlineData("x^2 + y_i")] + [InlineData("\\mathbf{A} \\times \\mathbf{B}")] + [InlineData("\\overline{x}")] + [InlineData("\\left( \\frac{1}{2} \\right)")] + [InlineData("\\lambda_{max} \\approx \\pi \\cdot \\tau")] + [InlineData("\\sum_{i=1}^{n} x_i")] + [InlineData("\\text{Mn} = \\chi \\varepsilon")] + public void Costrutti_latex_comuni(string latex) + { + var (_, view) = Show($"Prima $${latex}$$ dopo".Replace("$$", "$")); + var f = Assert.Single(Formulas(view)); + Assert.Equal(latex, AutomationProperties.GetName(f)); + Assert.True(string.IsNullOrEmpty(f.ErrorMessage), f.ErrorMessage); + Assert.True(f.Bounds.Width > 0); + } + + [AvaloniaFact] + public void Formula_cambio_tema() + { + var (window, view) = Show("Gap $E_g$ e\n\n$$\\frac{a}{b}$$"); + Assert.Equal(2, Formulas(view).Count); + window.RequestedThemeVariant = ThemeVariant.Dark; + Dispatcher.UIThread.RunJobs(); + Assert.Equal(2, Formulas(view).Count); + window.RequestedThemeVariant = ThemeVariant.Light; + Dispatcher.UIThread.RunJobs(); + Assert.Equal(2, Formulas(view).Count); + } + + [AvaloniaFact] + public void Formula_in_contesti_annidati() + { + var markdown = """ + Testo $a_1$ e un [link](https://example.com). + + [vai a $b^2$](https://example.com) + + - voce $c_3$ + + | x | y | + |---|---| + | $d_4$ | z | + """; + var (_, view) = Show(markdown); + Assert.Equal(4, Formulas(view).Count); + Assert.Contains("link", Text(view)); + Assert.Contains("vai a", Text(view)); + } + + [AvaloniaFact] + public void Formula_nome_accessibile() + { + var (_, view) = Show("Gap $E_g$."); + var f = Assert.Single(Formulas(view)); + Assert.Equal("E_g", AutomationProperties.GetName(f)); + } + + [AvaloniaFact] + public void Dollari_isolati_restano_testo() + { + var (_, view) = Show("Costa 10 $ e poi 20 $ in totale."); + Assert.Empty(Formulas(view)); + Assert.Contains("10 $ e poi 20 $", Text(view)); + } +}