diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 95520ca..38e03e8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,3 +34,8 @@ jobs: # Fail the build if any dependency is not open source. - name: Licence compliance (make licenses) run: make licenses + + # Fail the build if a vendored JS bundle drifted from its manifest or a + # pinned version has a known vulnerability (queries the OSV database). + - name: Bundled JS security (make deps-check) + run: make deps-check diff --git a/Makefile b/Makefile index 64db7f6..3c48515 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: setup format format-check analyze test test-contracts test-preview test-export test-state test-services test-presenter deps-outdated licenses check check-full help +.PHONY: setup format format-check analyze test test-contracts test-preview test-export test-state test-services test-presenter deps-outdated deps-check licenses check check-full help help: @echo "OciDeck quality targets:" @@ -11,6 +11,7 @@ help: @echo " make test-services Caption/description/image service tests." @echo " make test-presenter Fullscreen presenter interaction tests." @echo " make deps-outdated Advisory dependency freshness report." + @echo " make deps-check Verify vendored JS bundles vs manifest + OSV CVEs." @echo " make licenses Verify all dependencies use open-source licences." # Install Flutter/Dart dependencies. @@ -106,6 +107,18 @@ deps-outdated: @echo "Failure means: inspect network/tooling first; outdated packages are not necessarily regressions." flutter pub outdated +# Security gate for the vendored JS bundles inlined into the HTML export. +# Verifies each file still matches assets/web_export/MANIFEST.json (sha256) and +# queries the OSV database for known vulnerabilities in the pinned versions. +deps-check: + @echo "== OciDeck check: bundled JavaScript ==" + @echo "Command: dart run tool/check_bundled_js.dart" + @echo "Covers: integrity (sha256 vs manifest) + known CVEs (OSV) for marked," + @echo " highlight.js, DOMPurify, mermaid and MathJax." + @echo "Failure means: a bundle drifted from the manifest, or a pinned version" + @echo " now has a known vulnerability — upgrade it and refresh the manifest." + dart run tool/check_bundled_js.dart + # Open-source licence compliance check for all resolved dependencies. licenses: @echo "== OciDeck check: licences ==" @@ -120,6 +133,6 @@ check: format-check analyze test @echo "Validated: formatting, static analysis, and the full Flutter test suite." # Extended local check with advisory dependency freshness after the required gate. -check-full: check licenses deps-outdated +check-full: check licenses deps-check deps-outdated @echo "== OciDeck extended check complete ==" - @echo "Validated: required quality gate, licence compliance, and dependency freshness." + @echo "Validated: required quality gate, licence compliance, bundled-JS CVEs, and dependency freshness." diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index d36d68a..6324818 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -14,10 +14,16 @@ Shipped inside the app and embedded into the **offline HTML export** | --- | --- | --- | | [marked](https://github.com/markedjs/marked) | Markdown → HTML in the export | MIT | | [highlight.js](https://github.com/highlightjs/highlight.js) | Code highlighting in the export | BSD-3-Clause | -| [Mermaid](https://github.com/mermaid-js/mermaid) | Diagrams in the export | MIT (bundles [DOMPurify](https://github.com/cure53/DOMPurify), Apache-2.0 / MPL-2.0) | +| [DOMPurify](https://github.com/cure53/DOMPurify) | Sanitises the rendered Markdown before it hits the DOM in the export | Apache-2.0 / MPL-2.0 | +| [Mermaid](https://github.com/mermaid-js/mermaid) | Diagrams in the export | MIT | | [MathJax](https://github.com/mathjax/MathJax) (`tex-svg.js`) | Math rendering in the export | Apache-2.0 | | [EB Garamond](https://github.com/octaviopardo/EBGaramond12) font | Bundled deck font | SIL Open Font License 1.1 | +The exact pinned version, source URL and SHA-256 of every vendored JS bundle +live in [`assets/web_export/MANIFEST.json`](assets/web_export/MANIFEST.json). +`make deps-check` verifies each file still matches that manifest and queries the +[OSV](https://osv.dev) database for known vulnerabilities. + ## Vendored (forked) plugins Kept in `third_party/` and wired in via `pubspec.yaml` (path dependency / diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 6c60bfb..cb0988f 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -1,9 +1,23 @@ +import java.util.Properties +import java.io.FileInputStream + plugins { id("com.android.application") // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. id("dev.flutter.flutter-gradle-plugin") } +// Release signing is read from android/key.properties (kept out of version +// control). When it is absent we fall back to the debug key so that +// `flutter run --release` keeps working during development — but a build meant +// for distribution must provide a real keystore here. +val keystoreProperties = Properties() +val keystorePropertiesFile = rootProject.file("key.properties") +val hasReleaseKeystore = keystorePropertiesFile.exists() +if (hasReleaseKeystore) { + keystoreProperties.load(FileInputStream(keystorePropertiesFile)) +} + android { namespace = "com.example.ocideck" compileSdk = flutter.compileSdkVersion @@ -25,11 +39,27 @@ android { versionName = flutter.versionName } + signingConfigs { + if (hasReleaseKeystore) { + create("release") { + keyAlias = keystoreProperties["keyAlias"] as String + keyPassword = keystoreProperties["keyPassword"] as String + storeFile = file(keystoreProperties["storeFile"] as String) + storePassword = keystoreProperties["storePassword"] as String + } + } + } + buildTypes { release { - // TODO: Add your own signing config for the release build. - // Signing with the debug keys for now, so `flutter run --release` works. - signingConfig = signingConfigs.getByName("debug") + // Use the real release keystore when configured; otherwise fall back + // to the debug key so `flutter run --release` still works locally. + // Do NOT distribute a build signed with the debug key. + signingConfig = if (hasReleaseKeystore) { + signingConfigs.getByName("release") + } else { + signingConfigs.getByName("debug") + } } } } diff --git a/assets/web_export/MANIFEST.json b/assets/web_export/MANIFEST.json new file mode 100644 index 0000000..f74353e --- /dev/null +++ b/assets/web_export/MANIFEST.json @@ -0,0 +1,46 @@ +{ + "_comment": "Pinned inventory of the vendored JavaScript bundles inlined into the offline HTML export (see lib/services/marp_html_service.dart). Each entry records the npm package + exact version so `make deps-check` can query the OSV vulnerability database, and a sha256 so the same check can prove the on-disk file still matches this manifest (tamper / accidental-replacement guard). When you intentionally upgrade a bundle, update its version, source and sha256 here in the same commit.", + "ecosystem": "npm", + "bundles": [ + { + "file": "marked.min.js", + "npm": "marked", + "version": "18.0.5", + "sha256": "2dc4769dfde29f51c7aca1a539c6407c789c8ea644cf8b7d01ded28a9c1d800b", + "source": "https://cdn.jsdelivr.net/npm/marked@18.0.5/lib/marked.umd.js", + "license": "MIT" + }, + { + "file": "highlight.min.js", + "npm": "highlight.js", + "version": "11.11.1", + "sha256": "c4a399dd6f488bc97a3546e3476747b3e714c99c57b9473154c6fb8d259b9381", + "source": "https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.11.1/highlight.min.js", + "license": "BSD-3-Clause" + }, + { + "file": "purify.min.js", + "npm": "dompurify", + "version": "3.4.9", + "sha256": "3c16cc90eb152b823b71b8585cd79e7fb7cd7a380157a800dfbd9459aad5f726", + "source": "https://cdn.jsdelivr.net/npm/dompurify@3.4.9/dist/purify.min.js", + "license": "Apache-2.0 OR MPL-2.0" + }, + { + "file": "mermaid.min.js", + "npm": "mermaid", + "version": "10.9.6", + "sha256": "eda3a0ad572bbe69a318c1be0163e8233dd824f3f12939e5168feba207767151", + "source": "https://cdn.jsdelivr.net/npm/mermaid@10.9.6/dist/mermaid.min.js", + "license": "MIT" + }, + { + "file": "tex-svg.js", + "npm": "mathjax", + "version": "3.2.2", + "sha256": "d4295dc33744836935c1399feece5159577b34c5c8ffb9f1c6324cd82e03a882", + "source": "https://cdn.jsdelivr.net/npm/mathjax@3.2.2/es5/tex-svg.js", + "license": "Apache-2.0" + } + ] +} diff --git a/assets/web_export/highlight.min.js b/assets/web_export/highlight.min.js index 5d699ae..6e1a09e 100644 --- a/assets/web_export/highlight.min.js +++ b/assets/web_export/highlight.min.js @@ -1,6 +1,6 @@ /*! - Highlight.js v11.9.0 (git: f47103d4f1) - (c) 2006-2023 undefined and other contributors + Highlight.js v11.11.1 (git: 08cb242e7d) + (c) 2006-2024 Josh Goebel and other contributors License: BSD-3-Clause */ var hljs=function(){"use strict";function e(n){ @@ -54,28 +54,28 @@ const _=/\[(?:[^\\\]]|\\.)*\]|\(\??|\\([1-9][0-9]*)|\\./ i+=a.substring(0,e.index), a=a.substring(e.index+e[0].length),"\\"===e[0][0]&&e[1]?i+="\\"+(Number(e[1])+n):(i+=e[0], "("===e[0]&&t++)}return i})).map((e=>`(${e})`)).join(n)} -const f="[a-zA-Z]\\w*",E="[a-zA-Z_]\\w*",y="\\b\\d+(\\.\\d+)?",N="(-?)(\\b0[xX][a-fA-F0-9]+|(\\b\\d+(\\.\\d*)?|\\.\\d+)([eE][-+]?\\d+)?)",w="\\b(0b[01]+)",v={ -begin:"\\\\[\\s\\S]",relevance:0},O={scope:"string",begin:"'",end:"'", -illegal:"\\n",contains:[v]},k={scope:"string",begin:'"',end:'"',illegal:"\\n", -contains:[v]},x=(e,n,t={})=>{const i=a({scope:"comment",begin:e,end:n, +const f="[a-zA-Z]\\w*",E="[a-zA-Z_]\\w*",y="\\b\\d+(\\.\\d+)?",w="(-?)(\\b0[xX][a-fA-F0-9]+|(\\b\\d+(\\.\\d*)?|\\.\\d+)([eE][-+]?\\d+)?)",v="\\b(0b[01]+)",N={ +begin:"\\\\[\\s\\S]",relevance:0},k={scope:"string",begin:"'",end:"'", +illegal:"\\n",contains:[N]},x={scope:"string",begin:'"',end:'"',illegal:"\\n", +contains:[N]},O=(e,n,t={})=>{const i=a({scope:"comment",begin:e,end:n, contains:[]},t);i.contains.push({scope:"doctag", begin:"[ ]*(?=(TODO|FIXME|NOTE|BUG|OPTIMIZE|HACK|XXX):)", end:/(TODO|FIXME|NOTE|BUG|OPTIMIZE|HACK|XXX):/,excludeBegin:!0,relevance:0}) ;const r=m("I","a","is","so","us","to","at","if","in","it","on",/[A-Za-z]+['](d|ve|re|ll|t|s|n)/,/[A-Za-z]+[-][a-z]+/,/[A-Za-z][a-z]{2,}/) ;return i.contains.push({begin:b(/[ ]+/,"(",r,/[.]?[:]?([.][ ]|[ ])/,"){3}")}),i -},M=x("//","$"),S=x("/\\*","\\*/"),A=x("#","$");var C=Object.freeze({ -__proto__:null,APOS_STRING_MODE:O,BACKSLASH_ESCAPE:v,BINARY_NUMBER_MODE:{ -scope:"number",begin:w,relevance:0},BINARY_NUMBER_RE:w,COMMENT:x, -C_BLOCK_COMMENT_MODE:S,C_LINE_COMMENT_MODE:M,C_NUMBER_MODE:{scope:"number", -begin:N,relevance:0},C_NUMBER_RE:N,END_SAME_AS_BEGIN:e=>Object.assign(e,{ +},M=O("//","$"),A=O("/\\*","\\*/"),S=O("#","$");var C=Object.freeze({ +__proto__:null,APOS_STRING_MODE:k,BACKSLASH_ESCAPE:N,BINARY_NUMBER_MODE:{ +scope:"number",begin:v,relevance:0},BINARY_NUMBER_RE:v,COMMENT:O, +C_BLOCK_COMMENT_MODE:A,C_LINE_COMMENT_MODE:M,C_NUMBER_MODE:{scope:"number", +begin:w,relevance:0},C_NUMBER_RE:w,END_SAME_AS_BEGIN:e=>Object.assign(e,{ "on:begin":(e,n)=>{n.data._beginMatch=e[1]},"on:end":(e,n)=>{ -n.data._beginMatch!==e[1]&&n.ignoreMatch()}}),HASH_COMMENT_MODE:A,IDENT_RE:f, +n.data._beginMatch!==e[1]&&n.ignoreMatch()}}),HASH_COMMENT_MODE:S,IDENT_RE:f, MATCH_NOTHING_RE:/\b\B/,METHOD_GUARD:{begin:"\\.\\s*"+E,relevance:0}, NUMBER_MODE:{scope:"number",begin:y,relevance:0},NUMBER_RE:y, PHRASAL_WORDS_MODE:{ begin:/\b(a|an|the|are|I'm|isn't|don't|doesn't|won't|but|just|should|pretty|simply|enough|gonna|going|wtf|so|such|will|you|your|they|like|more)\b/ -},QUOTE_STRING_MODE:k,REGEXP_MODE:{scope:"regexp",begin:/\/(?=[^/\n]*\/)/, -end:/\/[gimuy]*/,contains:[v,{begin:/\[/,end:/\]/,relevance:0,contains:[v]}]}, +},QUOTE_STRING_MODE:x,REGEXP_MODE:{scope:"regexp",begin:/\/(?=[^/\n]*\/)/, +end:/\/[gimuy]*/,contains:[N,{begin:/\[/,end:/\]/,relevance:0,contains:[N]}]}, RE_STARTERS_RE:"!|!=|!==|%|%=|&|&&|&=|\\*|\\*=|\\+|\\+=|,|-|-=|/=|/|:|;|<<|<<=|<=|<|===|==|=|>>>=|>>=|>=|>>>|>>|>|\\?|\\[|\\{|\\(|\\^|\\^=|\\||\\|=|\\|\\||~", SHEBANG:(e={})=>{const n=/^#![ ]*\// ;return e.binary&&(e.begin=b(n,/.*\b/,e.binary,/\b.*/)),a({scope:"meta",begin:n, @@ -97,31 +97,31 @@ void 0===e.relevance&&(e.relevance=1)}const $=(e,n)=>{if(!e.beforeMatch)return })),e.keywords=t.keywords,e.begin=b(t.beforeMatch,d(t.begin)),e.starts={ relevance:0,contains:[Object.assign(t,{endsParent:!0})] },e.relevance=0,delete t.beforeMatch -},z=["of","and","for","in","not","or","if","then","parent","list","value"],F="keyword" -;function U(e,n,t=F){const a=Object.create(null) +},F=["of","and","for","in","not","or","if","then","parent","list","value"] +;function z(e,n,t="keyword"){const a=Object.create(null) ;return"string"==typeof e?i(t,e.split(" ")):Array.isArray(e)?i(t,e):Object.keys(e).forEach((t=>{ -Object.assign(a,U(e[t],n,t))})),a;function i(e,t){ +Object.assign(a,z(e[t],n,t))})),a;function i(e,t){ n&&(t=t.map((e=>e.toLowerCase()))),t.forEach((n=>{const t=n.split("|") ;a[t[0]]=[e,j(t[0],t[1])]}))}}function j(e,n){ -return n?Number(n):(e=>z.includes(e.toLowerCase()))(e)?0:1}const P={},K=e=>{ -console.error(e)},H=(e,...n)=>{console.log("WARN: "+e,...n)},q=(e,n)=>{ -P[`${e}/${n}`]||(console.log(`Deprecated as of ${e}. ${n}`),P[`${e}/${n}`]=!0) -},G=Error();function Z(e,n,{key:t}){let a=0;const i=e[t],r={},s={} +return n?Number(n):(e=>F.includes(e.toLowerCase()))(e)?0:1}const U={},P=e=>{ +console.error(e)},K=(e,...n)=>{console.log("WARN: "+e,...n)},q=(e,n)=>{ +U[`${e}/${n}`]||(console.log(`Deprecated as of ${e}. ${n}`),U[`${e}/${n}`]=!0) +},H=Error();function G(e,n,{key:t}){let a=0;const i=e[t],r={},s={} ;for(let e=1;e<=n.length;e++)s[e+a]=i[e],r[e+a]=!0,a+=p(n[e-1]) -;e[t]=s,e[t]._emit=r,e[t]._multi=!0}function W(e){(e=>{ +;e[t]=s,e[t]._emit=r,e[t]._multi=!0}function Z(e){(e=>{ e.scope&&"object"==typeof e.scope&&null!==e.scope&&(e.beginScope=e.scope, delete e.scope)})(e),"string"==typeof e.beginScope&&(e.beginScope={ _wrap:e.beginScope}),"string"==typeof e.endScope&&(e.endScope={_wrap:e.endScope }),(e=>{if(Array.isArray(e.begin)){ -if(e.skip||e.excludeBegin||e.returnBegin)throw K("skip, excludeBegin, returnBegin not compatible with beginScope: {}"), -G -;if("object"!=typeof e.beginScope||null===e.beginScope)throw K("beginScope must be object"), -G;Z(e,e.begin,{key:"beginScope"}),e.begin=h(e.begin,{joinWith:""})}})(e),(e=>{ +if(e.skip||e.excludeBegin||e.returnBegin)throw P("skip, excludeBegin, returnBegin not compatible with beginScope: {}"), +H +;if("object"!=typeof e.beginScope||null===e.beginScope)throw P("beginScope must be object"), +H;G(e,e.begin,{key:"beginScope"}),e.begin=h(e.begin,{joinWith:""})}})(e),(e=>{ if(Array.isArray(e.end)){ -if(e.skip||e.excludeEnd||e.returnEnd)throw K("skip, excludeEnd, returnEnd not compatible with endScope: {}"), -G -;if("object"!=typeof e.endScope||null===e.endScope)throw K("endScope must be object"), -G;Z(e,e.end,{key:"endScope"}),e.end=h(e.end,{joinWith:""})}})(e)}function Q(e){ +if(e.skip||e.excludeEnd||e.returnEnd)throw P("skip, excludeEnd, returnEnd not compatible with endScope: {}"), +H +;if("object"!=typeof e.endScope||null===e.endScope)throw P("endScope must be object"), +H;G(e,e.end,{key:"endScope"}),e.end=h(e.end,{joinWith:""})}})(e)}function W(e){ function n(n,t){ return RegExp(c(n),"m"+(e.case_insensitive?"i":"")+(e.unicodeRegex?"u":"")+(t?"g":"")) }class t{constructor(){ @@ -151,27 +151,27 @@ if(e.compilerExtensions||(e.compilerExtensions=[]), e.contains&&e.contains.includes("self"))throw Error("ERR: contains `self` is not supported at the top-level of a language. See documentation.") ;return e.classNameAliases=a(e.classNameAliases||{}),function t(r,s){const o=r ;if(r.isCompiled)return o -;[R,L,W,$].forEach((e=>e(r,s))),e.compilerExtensions.forEach((e=>e(r,s))), +;[R,L,Z,$].forEach((e=>e(r,s))),e.compilerExtensions.forEach((e=>e(r,s))), r.__beforeBegin=null,[D,I,B].forEach((e=>e(r,s))),r.isCompiled=!0;let l=null ;return"object"==typeof r.keywords&&r.keywords.$pattern&&(r.keywords=Object.assign({},r.keywords), l=r.keywords.$pattern, -delete r.keywords.$pattern),l=l||/\w+/,r.keywords&&(r.keywords=U(r.keywords,e.case_insensitive)), +delete r.keywords.$pattern),l=l||/\w+/,r.keywords&&(r.keywords=z(r.keywords,e.case_insensitive)), o.keywordPatternRe=n(l,!0), s&&(r.begin||(r.begin=/\B|\b/),o.beginRe=n(o.begin),r.end||r.endsWithParent||(r.end=/\B|\b/), r.end&&(o.endRe=n(o.end)), o.terminatorEnd=c(o.end)||"",r.endsWithParent&&s.terminatorEnd&&(o.terminatorEnd+=(r.end?"|":"")+s.terminatorEnd)), r.illegal&&(o.illegalRe=n(r.illegal)), r.contains||(r.contains=[]),r.contains=[].concat(...r.contains.map((e=>(e=>(e.variants&&!e.cachedVariants&&(e.cachedVariants=e.variants.map((n=>a(e,{ -variants:null},n)))),e.cachedVariants?e.cachedVariants:X(e)?a(e,{ +variants:null},n)))),e.cachedVariants?e.cachedVariants:Q(e)?a(e,{ starts:e.starts?a(e.starts):null }):Object.isFrozen(e)?a(e):e))("self"===e?r:e)))),r.contains.forEach((e=>{t(e,o) })),r.starts&&t(r.starts,s),o.matcher=(e=>{const n=new i ;return e.contains.forEach((e=>n.addRule(e.begin,{rule:e,type:"begin" }))),e.terminatorEnd&&n.addRule(e.terminatorEnd,{type:"end" -}),e.illegal&&n.addRule(e.illegal,{type:"illegal"}),n})(o),o}(e)}function X(e){ -return!!e&&(e.endsWithParent||X(e.starts))}class V extends Error{ +}),e.illegal&&n.addRule(e.illegal,{type:"illegal"}),n})(o),o}(e)}function Q(e){ +return!!e&&(e.endsWithParent||Q(e.starts))}class X extends Error{ constructor(e,n){super(e),this.name="HTMLInjectionError",this.html=n}} -const J=t,Y=a,ee=Symbol("nomatch"),ne=t=>{ +const V=t,J=a,Y=Symbol("nomatch"),ee=t=>{ const a=Object.create(null),i=Object.create(null),r=[];let s=!0 ;const o="Could not find the language '{}', did you forget to load/include a language module?",c={ disableAutodetect:!0,name:"Plain text",contains:[]};let p={ @@ -182,131 +182,130 @@ return p.noHighlightRe.test(e)}function h(e,n,t){let a="",i="" ;"object"==typeof n?(a=e, t=n.ignoreIllegals,i=n.language):(q("10.7.0","highlight(lang, code, ...args) has been deprecated."), q("10.7.0","Please use highlight(code, options) instead.\nhttps://github.com/highlightjs/highlight.js/issues/2277"), -i=e,a=n),void 0===t&&(t=!0);const r={code:a,language:i};x("before:highlight",r) +i=e,a=n),void 0===t&&(t=!0);const r={code:a,language:i};O("before:highlight",r) ;const s=r.result?r.result:f(r.language,r.code,t) -;return s.code=r.code,x("after:highlight",s),s}function f(e,t,i,r){ -const l=Object.create(null);function c(){if(!x.keywords)return void S.addText(A) -;let e=0;x.keywordPatternRe.lastIndex=0;let n=x.keywordPatternRe.exec(A),t="" -;for(;n;){t+=A.substring(e,n.index) -;const i=w.case_insensitive?n[0].toLowerCase():n[0],r=(a=i,x.keywords[a]);if(r){ +;return s.code=r.code,O("after:highlight",s),s}function f(e,t,i,r){ +const l=Object.create(null);function c(){if(!O.keywords)return void A.addText(S) +;let e=0;O.keywordPatternRe.lastIndex=0;let n=O.keywordPatternRe.exec(S),t="" +;for(;n;){t+=S.substring(e,n.index) +;const i=v.case_insensitive?n[0].toLowerCase():n[0],r=(a=i,O.keywords[a]);if(r){ const[e,a]=r -;if(S.addText(t),t="",l[i]=(l[i]||0)+1,l[i]<=7&&(C+=a),e.startsWith("_"))t+=n[0];else{ -const t=w.classNameAliases[e]||e;g(n[0],t)}}else t+=n[0] -;e=x.keywordPatternRe.lastIndex,n=x.keywordPatternRe.exec(A)}var a -;t+=A.substring(e),S.addText(t)}function d(){null!=x.subLanguage?(()=>{ -if(""===A)return;let e=null;if("string"==typeof x.subLanguage){ -if(!a[x.subLanguage])return void S.addText(A) -;e=f(x.subLanguage,A,!0,M[x.subLanguage]),M[x.subLanguage]=e._top -}else e=E(A,x.subLanguage.length?x.subLanguage:null) -;x.relevance>0&&(C+=e.relevance),S.__addSublanguage(e._emitter,e.language) -})():c(),A=""}function g(e,n){ -""!==e&&(S.startScope(n),S.addText(e),S.endScope())}function u(e,n){let t=1 +;if(A.addText(t),t="",l[i]=(l[i]||0)+1,l[i]<=7&&(C+=a),e.startsWith("_"))t+=n[0];else{ +const t=v.classNameAliases[e]||e;g(n[0],t)}}else t+=n[0] +;e=O.keywordPatternRe.lastIndex,n=O.keywordPatternRe.exec(S)}var a +;t+=S.substring(e),A.addText(t)}function d(){null!=O.subLanguage?(()=>{ +if(""===S)return;let e=null;if("string"==typeof O.subLanguage){ +if(!a[O.subLanguage])return void A.addText(S) +;e=f(O.subLanguage,S,!0,M[O.subLanguage]),M[O.subLanguage]=e._top +}else e=E(S,O.subLanguage.length?O.subLanguage:null) +;O.relevance>0&&(C+=e.relevance),A.__addSublanguage(e._emitter,e.language) +})():c(),S=""}function g(e,n){ +""!==e&&(A.startScope(n),A.addText(e),A.endScope())}function u(e,n){let t=1 ;const a=n.length-1;for(;t<=a;){if(!e._emit[t]){t++;continue} -const a=w.classNameAliases[e[t]]||e[t],i=n[t];a?g(i,a):(A=i,c(),A=""),t++}} +const a=v.classNameAliases[e[t]]||e[t],i=n[t];a?g(i,a):(S=i,c(),S=""),t++}} function b(e,n){ -return e.scope&&"string"==typeof e.scope&&S.openNode(w.classNameAliases[e.scope]||e.scope), -e.beginScope&&(e.beginScope._wrap?(g(A,w.classNameAliases[e.beginScope._wrap]||e.beginScope._wrap), -A=""):e.beginScope._multi&&(u(e.beginScope,n),A="")),x=Object.create(e,{parent:{ -value:x}}),x}function m(e,t,a){let i=((e,n)=>{const t=e&&e.exec(n) +return e.scope&&"string"==typeof e.scope&&A.openNode(v.classNameAliases[e.scope]||e.scope), +e.beginScope&&(e.beginScope._wrap?(g(S,v.classNameAliases[e.beginScope._wrap]||e.beginScope._wrap), +S=""):e.beginScope._multi&&(u(e.beginScope,n),S="")),O=Object.create(e,{parent:{ +value:O}}),O}function m(e,t,a){let i=((e,n)=>{const t=e&&e.exec(n) ;return t&&0===t.index})(e.endRe,a);if(i){if(e["on:end"]){const a=new n(e) ;e["on:end"](t,a),a.isMatchIgnored&&(i=!1)}if(i){ for(;e.endsParent&&e.parent;)e=e.parent;return e}} if(e.endsWithParent)return m(e.parent,t,a)}function _(e){ -return 0===x.matcher.regexIndex?(A+=e[0],1):(D=!0,0)}function h(e){ -const n=e[0],a=t.substring(e.index),i=m(x,e,a);if(!i)return ee;const r=x -;x.endScope&&x.endScope._wrap?(d(), -g(n,x.endScope._wrap)):x.endScope&&x.endScope._multi?(d(), -u(x.endScope,e)):r.skip?A+=n:(r.returnEnd||r.excludeEnd||(A+=n), -d(),r.excludeEnd&&(A=n));do{ -x.scope&&S.closeNode(),x.skip||x.subLanguage||(C+=x.relevance),x=x.parent -}while(x!==i.parent);return i.starts&&b(i.starts,e),r.returnEnd?0:n.length} -let y={};function N(a,r){const o=r&&r[0];if(A+=a,null==o)return d(),0 +return 0===O.matcher.regexIndex?(S+=e[0],1):(D=!0,0)}function h(e){ +const n=e[0],a=t.substring(e.index),i=m(O,e,a);if(!i)return Y;const r=O +;O.endScope&&O.endScope._wrap?(d(), +g(n,O.endScope._wrap)):O.endScope&&O.endScope._multi?(d(), +u(O.endScope,e)):r.skip?S+=n:(r.returnEnd||r.excludeEnd||(S+=n), +d(),r.excludeEnd&&(S=n));do{ +O.scope&&A.closeNode(),O.skip||O.subLanguage||(C+=O.relevance),O=O.parent +}while(O!==i.parent);return i.starts&&b(i.starts,e),r.returnEnd?0:n.length} +let y={};function w(a,r){const o=r&&r[0];if(S+=a,null==o)return d(),0 ;if("begin"===y.type&&"end"===r.type&&y.index===r.index&&""===o){ -if(A+=t.slice(r.index,r.index+1),!s){const n=Error(`0 width match regex (${e})`) +if(S+=t.slice(r.index,r.index+1),!s){const n=Error(`0 width match regex (${e})`) ;throw n.languageName=e,n.badRule=y.rule,n}return 1} if(y=r,"begin"===r.type)return(e=>{ const t=e[0],a=e.rule,i=new n(a),r=[a.__beforeBegin,a["on:begin"]] ;for(const n of r)if(n&&(n(e,i),i.isMatchIgnored))return _(t) -;return a.skip?A+=t:(a.excludeBegin&&(A+=t), -d(),a.returnBegin||a.excludeBegin||(A=t)),b(a,e),a.returnBegin?0:t.length})(r) +;return a.skip?S+=t:(a.excludeBegin&&(S+=t), +d(),a.returnBegin||a.excludeBegin||(S=t)),b(a,e),a.returnBegin?0:t.length})(r) ;if("illegal"===r.type&&!i){ -const e=Error('Illegal lexeme "'+o+'" for mode "'+(x.scope||"")+'"') -;throw e.mode=x,e}if("end"===r.type){const e=h(r);if(e!==ee)return e} -if("illegal"===r.type&&""===o)return 1 +const e=Error('Illegal lexeme "'+o+'" for mode "'+(O.scope||"")+'"') +;throw e.mode=O,e}if("end"===r.type){const e=h(r);if(e!==Y)return e} +if("illegal"===r.type&&""===o)return S+="\n",1 ;if(R>1e5&&R>3*r.index)throw Error("potential infinite loop, way more iterations than matches") -;return A+=o,o.length}const w=v(e) -;if(!w)throw K(o.replace("{}",e)),Error('Unknown language: "'+e+'"') -;const O=Q(w);let k="",x=r||O;const M={},S=new p.__emitter(p);(()=>{const e=[] -;for(let n=x;n!==w;n=n.parent)n.scope&&e.unshift(n.scope) -;e.forEach((e=>S.openNode(e)))})();let A="",C=0,T=0,R=0,D=!1;try{ -if(w.__emitTokens)w.__emitTokens(t,S);else{for(x.matcher.considerAll();;){ -R++,D?D=!1:x.matcher.considerAll(),x.matcher.lastIndex=T -;const e=x.matcher.exec(t);if(!e)break;const n=N(t.substring(T,e.index),e) -;T=e.index+n}N(t.substring(T))}return S.finalize(),k=S.toHTML(),{language:e, -value:k,relevance:C,illegal:!1,_emitter:S,_top:x}}catch(n){ -if(n.message&&n.message.includes("Illegal"))return{language:e,value:J(t), +;return S+=o,o.length}const v=N(e) +;if(!v)throw P(o.replace("{}",e)),Error('Unknown language: "'+e+'"') +;const k=W(v);let x="",O=r||k;const M={},A=new p.__emitter(p);(()=>{const e=[] +;for(let n=O;n!==v;n=n.parent)n.scope&&e.unshift(n.scope) +;e.forEach((e=>A.openNode(e)))})();let S="",C=0,T=0,R=0,D=!1;try{ +if(v.__emitTokens)v.__emitTokens(t,A);else{for(O.matcher.considerAll();;){ +R++,D?D=!1:O.matcher.considerAll(),O.matcher.lastIndex=T +;const e=O.matcher.exec(t);if(!e)break;const n=w(t.substring(T,e.index),e) +;T=e.index+n}w(t.substring(T))}return A.finalize(),x=A.toHTML(),{language:e, +value:x,relevance:C,illegal:!1,_emitter:A,_top:O}}catch(n){ +if(n.message&&n.message.includes("Illegal"))return{language:e,value:V(t), illegal:!0,relevance:0,_illegalBy:{message:n.message,index:T, -context:t.slice(T-100,T+100),mode:n.mode,resultSoFar:k},_emitter:S};if(s)return{ -language:e,value:J(t),illegal:!1,relevance:0,errorRaised:n,_emitter:S,_top:x} +context:t.slice(T-100,T+100),mode:n.mode,resultSoFar:x},_emitter:A};if(s)return{ +language:e,value:V(t),illegal:!1,relevance:0,errorRaised:n,_emitter:A,_top:O} ;throw n}}function E(e,n){n=n||p.languages||Object.keys(a);const t=(e=>{ -const n={value:J(e),illegal:!1,relevance:0,_top:c,_emitter:new p.__emitter(p)} -;return n._emitter.addText(e),n})(e),i=n.filter(v).filter(k).map((n=>f(n,e,!1))) +const n={value:V(e),illegal:!1,relevance:0,_top:c,_emitter:new p.__emitter(p)} +;return n._emitter.addText(e),n})(e),i=n.filter(N).filter(x).map((n=>f(n,e,!1))) ;i.unshift(t);const r=i.sort(((e,n)=>{ if(e.relevance!==n.relevance)return n.relevance-e.relevance -;if(e.language&&n.language){if(v(e.language).supersetOf===n.language)return 1 -;if(v(n.language).supersetOf===e.language)return-1}return 0})),[s,o]=r,l=s +;if(e.language&&n.language){if(N(e.language).supersetOf===n.language)return 1 +;if(N(n.language).supersetOf===e.language)return-1}return 0})),[s,o]=r,l=s ;return l.secondBest=o,l}function y(e){let n=null;const t=(e=>{ let n=e.className+" ";n+=e.parentNode?e.parentNode.className:"" -;const t=p.languageDetectRe.exec(n);if(t){const n=v(t[1]) -;return n||(H(o.replace("{}",t[1])), -H("Falling back to no-highlight mode for this block.",e)),n?t[1]:"no-highlight"} -return n.split(/\s+/).find((e=>_(e)||v(e)))})(e);if(_(t))return -;if(x("before:highlightElement",{el:e,language:t +;const t=p.languageDetectRe.exec(n);if(t){const n=N(t[1]) +;return n||(K(o.replace("{}",t[1])), +K("Falling back to no-highlight mode for this block.",e)),n?t[1]:"no-highlight"} +return n.split(/\s+/).find((e=>_(e)||N(e)))})(e);if(_(t))return +;if(O("before:highlightElement",{el:e,language:t }),e.dataset.highlighted)return void console.log("Element previously highlighted. To highlight again, first unset `dataset.highlighted`.",e) ;if(e.children.length>0&&(p.ignoreUnescapedHTML||(console.warn("One of your code blocks includes unescaped HTML. This is a potentially serious security risk."), console.warn("https://github.com/highlightjs/highlight.js/wiki/security"), console.warn("The element with unescaped HTML:"), -console.warn(e)),p.throwUnescapedHTML))throw new V("One of your code blocks includes unescaped HTML.",e.innerHTML) +console.warn(e)),p.throwUnescapedHTML))throw new X("One of your code blocks includes unescaped HTML.",e.innerHTML) ;n=e;const a=n.textContent,r=t?h(a,{language:t,ignoreIllegals:!0}):E(a) ;e.innerHTML=r.value,e.dataset.highlighted="yes",((e,n,t)=>{const a=n&&i[n]||t ;e.classList.add("hljs"),e.classList.add("language-"+a) })(e,t,r.language),e.result={language:r.language,re:r.relevance, relevance:r.relevance},r.secondBest&&(e.secondBest={ language:r.secondBest.language,relevance:r.secondBest.relevance -}),x("after:highlightElement",{el:e,result:r,text:a})}let N=!1;function w(){ -"loading"!==document.readyState?document.querySelectorAll(p.cssSelector).forEach(y):N=!0 -}function v(e){return e=(e||"").toLowerCase(),a[e]||a[i[e]]} -function O(e,{languageName:n}){"string"==typeof e&&(e=[e]),e.forEach((e=>{ -i[e.toLowerCase()]=n}))}function k(e){const n=v(e) -;return n&&!n.disableAutodetect}function x(e,n){const t=e;r.forEach((e=>{ -e[t]&&e[t](n)}))} -"undefined"!=typeof window&&window.addEventListener&&window.addEventListener("DOMContentLoaded",(()=>{ -N&&w()}),!1),Object.assign(t,{highlight:h,highlightAuto:E,highlightAll:w, +}),O("after:highlightElement",{el:e,result:r,text:a})}let w=!1;function v(){ +if("loading"===document.readyState)return w||window.addEventListener("DOMContentLoaded",(()=>{ +v()}),!1),void(w=!0);document.querySelectorAll(p.cssSelector).forEach(y)} +function N(e){return e=(e||"").toLowerCase(),a[e]||a[i[e]]} +function k(e,{languageName:n}){"string"==typeof e&&(e=[e]),e.forEach((e=>{ +i[e.toLowerCase()]=n}))}function x(e){const n=N(e) +;return n&&!n.disableAutodetect}function O(e,n){const t=e;r.forEach((e=>{ +e[t]&&e[t](n)}))}Object.assign(t,{highlight:h,highlightAuto:E,highlightAll:v, highlightElement:y, highlightBlock:e=>(q("10.7.0","highlightBlock will be removed entirely in v12.0"), -q("10.7.0","Please use highlightElement now."),y(e)),configure:e=>{p=Y(p,e)}, +q("10.7.0","Please use highlightElement now."),y(e)),configure:e=>{p=J(p,e)}, initHighlighting:()=>{ -w(),q("10.6.0","initHighlighting() deprecated. Use highlightAll() now.")}, +v(),q("10.6.0","initHighlighting() deprecated. Use highlightAll() now.")}, initHighlightingOnLoad:()=>{ -w(),q("10.6.0","initHighlightingOnLoad() deprecated. Use highlightAll() now.") +v(),q("10.6.0","initHighlightingOnLoad() deprecated. Use highlightAll() now.") },registerLanguage:(e,n)=>{let i=null;try{i=n(t)}catch(n){ -if(K("Language definition for '{}' could not be registered.".replace("{}",e)), -!s)throw n;K(n),i=c} -i.name||(i.name=e),a[e]=i,i.rawDefinition=n.bind(null,t),i.aliases&&O(i.aliases,{ +if(P("Language definition for '{}' could not be registered.".replace("{}",e)), +!s)throw n;P(n),i=c} +i.name||(i.name=e),a[e]=i,i.rawDefinition=n.bind(null,t),i.aliases&&k(i.aliases,{ languageName:e})},unregisterLanguage:e=>{delete a[e] ;for(const n of Object.keys(i))i[n]===e&&delete i[n]}, -listLanguages:()=>Object.keys(a),getLanguage:v,registerAliases:O, -autoDetection:k,inherit:Y,addPlugin:e=>{(e=>{ +listLanguages:()=>Object.keys(a),getLanguage:N,registerAliases:k, +autoDetection:x,inherit:J,addPlugin:e=>{(e=>{ e["before:highlightBlock"]&&!e["before:highlightElement"]&&(e["before:highlightElement"]=n=>{ e["before:highlightBlock"](Object.assign({block:n.el},n)) }),e["after:highlightBlock"]&&!e["after:highlightElement"]&&(e["after:highlightElement"]=n=>{ e["after:highlightBlock"](Object.assign({block:n.el},n))})})(e),r.push(e)}, removePlugin:e=>{const n=r.indexOf(e);-1!==n&&r.splice(n,1)}}),t.debugMode=()=>{ -s=!1},t.safeMode=()=>{s=!0},t.versionString="11.9.0",t.regex={concat:b, +s=!1},t.safeMode=()=>{s=!0},t.versionString="11.11.1",t.regex={concat:b, lookahead:d,either:m,optional:u,anyNumberOfTimes:g} ;for(const n in C)"object"==typeof C[n]&&e(C[n]);return Object.assign(t,C),t -},te=ne({});te.newInstance=()=>ne({});var ae=te;const ie=e=>({IMPORTANT:{ -scope:"meta",begin:"!important"},BLOCK_COMMENT:e.C_BLOCK_COMMENT_MODE,HEXCOLOR:{ +},ne=ee({});ne.newInstance=()=>ee({});const te=e=>({IMPORTANT:{scope:"meta", +begin:"!important"},BLOCK_COMMENT:e.C_BLOCK_COMMENT_MODE,HEXCOLOR:{ scope:"number",begin:/#(([0-9a-fA-F]{3,4})|(([0-9a-fA-F]{2}){3,4}))\b/}, FUNCTION_DISPATCH:{className:"built_in",begin:/[\w-]+(?=\()/}, ATTRIBUTE_SELECTOR_MODE:{scope:"selector-attr",begin:/\[/,end:/\]/,illegal:"$", @@ -314,18 +313,18 @@ contains:[e.APOS_STRING_MODE,e.QUOTE_STRING_MODE]},CSS_NUMBER_MODE:{ scope:"number", begin:e.NUMBER_RE+"(%|em|ex|ch|rem|vw|vh|vmin|vmax|cm|mm|in|pt|pc|px|deg|grad|rad|turn|s|ms|Hz|kHz|dpi|dpcm|dppx)?", relevance:0},CSS_VARIABLE:{className:"attr",begin:/--[A-Za-z_][A-Za-z0-9_-]*/} -}),re=["a","abbr","address","article","aside","audio","b","blockquote","body","button","canvas","caption","cite","code","dd","del","details","dfn","div","dl","dt","em","fieldset","figcaption","figure","footer","form","h1","h2","h3","h4","h5","h6","header","hgroup","html","i","iframe","img","input","ins","kbd","label","legend","li","main","mark","menu","nav","object","ol","p","q","quote","samp","section","span","strong","summary","sup","table","tbody","td","textarea","tfoot","th","thead","time","tr","ul","var","video"],se=["any-hover","any-pointer","aspect-ratio","color","color-gamut","color-index","device-aspect-ratio","device-height","device-width","display-mode","forced-colors","grid","height","hover","inverted-colors","monochrome","orientation","overflow-block","overflow-inline","pointer","prefers-color-scheme","prefers-contrast","prefers-reduced-motion","prefers-reduced-transparency","resolution","scan","scripting","update","width","min-width","max-width","min-height","max-height"],oe=["active","any-link","blank","checked","current","default","defined","dir","disabled","drop","empty","enabled","first","first-child","first-of-type","fullscreen","future","focus","focus-visible","focus-within","has","host","host-context","hover","indeterminate","in-range","invalid","is","lang","last-child","last-of-type","left","link","local-link","not","nth-child","nth-col","nth-last-child","nth-last-col","nth-last-of-type","nth-of-type","only-child","only-of-type","optional","out-of-range","past","placeholder-shown","read-only","read-write","required","right","root","scope","target","target-within","user-invalid","valid","visited","where"],le=["after","backdrop","before","cue","cue-region","first-letter","first-line","grammar-error","marker","part","placeholder","selection","slotted","spelling-error"],ce=["align-content","align-items","align-self","all","animation","animation-delay","animation-direction","animation-duration","animation-fill-mode","animation-iteration-count","animation-name","animation-play-state","animation-timing-function","backface-visibility","background","background-attachment","background-blend-mode","background-clip","background-color","background-image","background-origin","background-position","background-repeat","background-size","block-size","border","border-block","border-block-color","border-block-end","border-block-end-color","border-block-end-style","border-block-end-width","border-block-start","border-block-start-color","border-block-start-style","border-block-start-width","border-block-style","border-block-width","border-bottom","border-bottom-color","border-bottom-left-radius","border-bottom-right-radius","border-bottom-style","border-bottom-width","border-collapse","border-color","border-image","border-image-outset","border-image-repeat","border-image-slice","border-image-source","border-image-width","border-inline","border-inline-color","border-inline-end","border-inline-end-color","border-inline-end-style","border-inline-end-width","border-inline-start","border-inline-start-color","border-inline-start-style","border-inline-start-width","border-inline-style","border-inline-width","border-left","border-left-color","border-left-style","border-left-width","border-radius","border-right","border-right-color","border-right-style","border-right-width","border-spacing","border-style","border-top","border-top-color","border-top-left-radius","border-top-right-radius","border-top-style","border-top-width","border-width","bottom","box-decoration-break","box-shadow","box-sizing","break-after","break-before","break-inside","caption-side","caret-color","clear","clip","clip-path","clip-rule","color","column-count","column-fill","column-gap","column-rule","column-rule-color","column-rule-style","column-rule-width","column-span","column-width","columns","contain","content","content-visibility","counter-increment","counter-reset","cue","cue-after","cue-before","cursor","direction","display","empty-cells","filter","flex","flex-basis","flex-direction","flex-flow","flex-grow","flex-shrink","flex-wrap","float","flow","font","font-display","font-family","font-feature-settings","font-kerning","font-language-override","font-size","font-size-adjust","font-smoothing","font-stretch","font-style","font-synthesis","font-variant","font-variant-caps","font-variant-east-asian","font-variant-ligatures","font-variant-numeric","font-variant-position","font-variation-settings","font-weight","gap","glyph-orientation-vertical","grid","grid-area","grid-auto-columns","grid-auto-flow","grid-auto-rows","grid-column","grid-column-end","grid-column-start","grid-gap","grid-row","grid-row-end","grid-row-start","grid-template","grid-template-areas","grid-template-columns","grid-template-rows","hanging-punctuation","height","hyphens","icon","image-orientation","image-rendering","image-resolution","ime-mode","inline-size","isolation","justify-content","left","letter-spacing","line-break","line-height","list-style","list-style-image","list-style-position","list-style-type","margin","margin-block","margin-block-end","margin-block-start","margin-bottom","margin-inline","margin-inline-end","margin-inline-start","margin-left","margin-right","margin-top","marks","mask","mask-border","mask-border-mode","mask-border-outset","mask-border-repeat","mask-border-slice","mask-border-source","mask-border-width","mask-clip","mask-composite","mask-image","mask-mode","mask-origin","mask-position","mask-repeat","mask-size","mask-type","max-block-size","max-height","max-inline-size","max-width","min-block-size","min-height","min-inline-size","min-width","mix-blend-mode","nav-down","nav-index","nav-left","nav-right","nav-up","none","normal","object-fit","object-position","opacity","order","orphans","outline","outline-color","outline-offset","outline-style","outline-width","overflow","overflow-wrap","overflow-x","overflow-y","padding","padding-block","padding-block-end","padding-block-start","padding-bottom","padding-inline","padding-inline-end","padding-inline-start","padding-left","padding-right","padding-top","page-break-after","page-break-before","page-break-inside","pause","pause-after","pause-before","perspective","perspective-origin","pointer-events","position","quotes","resize","rest","rest-after","rest-before","right","row-gap","scroll-margin","scroll-margin-block","scroll-margin-block-end","scroll-margin-block-start","scroll-margin-bottom","scroll-margin-inline","scroll-margin-inline-end","scroll-margin-inline-start","scroll-margin-left","scroll-margin-right","scroll-margin-top","scroll-padding","scroll-padding-block","scroll-padding-block-end","scroll-padding-block-start","scroll-padding-bottom","scroll-padding-inline","scroll-padding-inline-end","scroll-padding-inline-start","scroll-padding-left","scroll-padding-right","scroll-padding-top","scroll-snap-align","scroll-snap-stop","scroll-snap-type","scrollbar-color","scrollbar-gutter","scrollbar-width","shape-image-threshold","shape-margin","shape-outside","speak","speak-as","src","tab-size","table-layout","text-align","text-align-all","text-align-last","text-combine-upright","text-decoration","text-decoration-color","text-decoration-line","text-decoration-style","text-emphasis","text-emphasis-color","text-emphasis-position","text-emphasis-style","text-indent","text-justify","text-orientation","text-overflow","text-rendering","text-shadow","text-transform","text-underline-position","top","transform","transform-box","transform-origin","transform-style","transition","transition-delay","transition-duration","transition-property","transition-timing-function","unicode-bidi","vertical-align","visibility","voice-balance","voice-duration","voice-family","voice-pitch","voice-range","voice-rate","voice-stress","voice-volume","white-space","widows","width","will-change","word-break","word-spacing","word-wrap","writing-mode","z-index"].reverse(),de=oe.concat(le) -;var ge="[0-9](_*[0-9])*",ue=`\\.(${ge})`,be="[0-9a-fA-F](_*[0-9a-fA-F])*",me={ +}),ae=["a","abbr","address","article","aside","audio","b","blockquote","body","button","canvas","caption","cite","code","dd","del","details","dfn","div","dl","dt","em","fieldset","figcaption","figure","footer","form","h1","h2","h3","h4","h5","h6","header","hgroup","html","i","iframe","img","input","ins","kbd","label","legend","li","main","mark","menu","nav","object","ol","optgroup","option","p","picture","q","quote","samp","section","select","source","span","strong","summary","sup","table","tbody","td","textarea","tfoot","th","thead","time","tr","ul","var","video","defs","g","marker","mask","pattern","svg","switch","symbol","feBlend","feColorMatrix","feComponentTransfer","feComposite","feConvolveMatrix","feDiffuseLighting","feDisplacementMap","feFlood","feGaussianBlur","feImage","feMerge","feMorphology","feOffset","feSpecularLighting","feTile","feTurbulence","linearGradient","radialGradient","stop","circle","ellipse","image","line","path","polygon","polyline","rect","text","use","textPath","tspan","foreignObject","clipPath"],ie=["any-hover","any-pointer","aspect-ratio","color","color-gamut","color-index","device-aspect-ratio","device-height","device-width","display-mode","forced-colors","grid","height","hover","inverted-colors","monochrome","orientation","overflow-block","overflow-inline","pointer","prefers-color-scheme","prefers-contrast","prefers-reduced-motion","prefers-reduced-transparency","resolution","scan","scripting","update","width","min-width","max-width","min-height","max-height"].sort().reverse(),re=["active","any-link","blank","checked","current","default","defined","dir","disabled","drop","empty","enabled","first","first-child","first-of-type","fullscreen","future","focus","focus-visible","focus-within","has","host","host-context","hover","indeterminate","in-range","invalid","is","lang","last-child","last-of-type","left","link","local-link","not","nth-child","nth-col","nth-last-child","nth-last-col","nth-last-of-type","nth-of-type","only-child","only-of-type","optional","out-of-range","past","placeholder-shown","read-only","read-write","required","right","root","scope","target","target-within","user-invalid","valid","visited","where"].sort().reverse(),se=["after","backdrop","before","cue","cue-region","first-letter","first-line","grammar-error","marker","part","placeholder","selection","slotted","spelling-error"].sort().reverse(),oe=["accent-color","align-content","align-items","align-self","alignment-baseline","all","anchor-name","animation","animation-composition","animation-delay","animation-direction","animation-duration","animation-fill-mode","animation-iteration-count","animation-name","animation-play-state","animation-range","animation-range-end","animation-range-start","animation-timeline","animation-timing-function","appearance","aspect-ratio","backdrop-filter","backface-visibility","background","background-attachment","background-blend-mode","background-clip","background-color","background-image","background-origin","background-position","background-position-x","background-position-y","background-repeat","background-size","baseline-shift","block-size","border","border-block","border-block-color","border-block-end","border-block-end-color","border-block-end-style","border-block-end-width","border-block-start","border-block-start-color","border-block-start-style","border-block-start-width","border-block-style","border-block-width","border-bottom","border-bottom-color","border-bottom-left-radius","border-bottom-right-radius","border-bottom-style","border-bottom-width","border-collapse","border-color","border-end-end-radius","border-end-start-radius","border-image","border-image-outset","border-image-repeat","border-image-slice","border-image-source","border-image-width","border-inline","border-inline-color","border-inline-end","border-inline-end-color","border-inline-end-style","border-inline-end-width","border-inline-start","border-inline-start-color","border-inline-start-style","border-inline-start-width","border-inline-style","border-inline-width","border-left","border-left-color","border-left-style","border-left-width","border-radius","border-right","border-right-color","border-right-style","border-right-width","border-spacing","border-start-end-radius","border-start-start-radius","border-style","border-top","border-top-color","border-top-left-radius","border-top-right-radius","border-top-style","border-top-width","border-width","bottom","box-align","box-decoration-break","box-direction","box-flex","box-flex-group","box-lines","box-ordinal-group","box-orient","box-pack","box-shadow","box-sizing","break-after","break-before","break-inside","caption-side","caret-color","clear","clip","clip-path","clip-rule","color","color-interpolation","color-interpolation-filters","color-profile","color-rendering","color-scheme","column-count","column-fill","column-gap","column-rule","column-rule-color","column-rule-style","column-rule-width","column-span","column-width","columns","contain","contain-intrinsic-block-size","contain-intrinsic-height","contain-intrinsic-inline-size","contain-intrinsic-size","contain-intrinsic-width","container","container-name","container-type","content","content-visibility","counter-increment","counter-reset","counter-set","cue","cue-after","cue-before","cursor","cx","cy","direction","display","dominant-baseline","empty-cells","enable-background","field-sizing","fill","fill-opacity","fill-rule","filter","flex","flex-basis","flex-direction","flex-flow","flex-grow","flex-shrink","flex-wrap","float","flood-color","flood-opacity","flow","font","font-display","font-family","font-feature-settings","font-kerning","font-language-override","font-optical-sizing","font-palette","font-size","font-size-adjust","font-smooth","font-smoothing","font-stretch","font-style","font-synthesis","font-synthesis-position","font-synthesis-small-caps","font-synthesis-style","font-synthesis-weight","font-variant","font-variant-alternates","font-variant-caps","font-variant-east-asian","font-variant-emoji","font-variant-ligatures","font-variant-numeric","font-variant-position","font-variation-settings","font-weight","forced-color-adjust","gap","glyph-orientation-horizontal","glyph-orientation-vertical","grid","grid-area","grid-auto-columns","grid-auto-flow","grid-auto-rows","grid-column","grid-column-end","grid-column-start","grid-gap","grid-row","grid-row-end","grid-row-start","grid-template","grid-template-areas","grid-template-columns","grid-template-rows","hanging-punctuation","height","hyphenate-character","hyphenate-limit-chars","hyphens","icon","image-orientation","image-rendering","image-resolution","ime-mode","initial-letter","initial-letter-align","inline-size","inset","inset-area","inset-block","inset-block-end","inset-block-start","inset-inline","inset-inline-end","inset-inline-start","isolation","justify-content","justify-items","justify-self","kerning","left","letter-spacing","lighting-color","line-break","line-height","line-height-step","list-style","list-style-image","list-style-position","list-style-type","margin","margin-block","margin-block-end","margin-block-start","margin-bottom","margin-inline","margin-inline-end","margin-inline-start","margin-left","margin-right","margin-top","margin-trim","marker","marker-end","marker-mid","marker-start","marks","mask","mask-border","mask-border-mode","mask-border-outset","mask-border-repeat","mask-border-slice","mask-border-source","mask-border-width","mask-clip","mask-composite","mask-image","mask-mode","mask-origin","mask-position","mask-repeat","mask-size","mask-type","masonry-auto-flow","math-depth","math-shift","math-style","max-block-size","max-height","max-inline-size","max-width","min-block-size","min-height","min-inline-size","min-width","mix-blend-mode","nav-down","nav-index","nav-left","nav-right","nav-up","none","normal","object-fit","object-position","offset","offset-anchor","offset-distance","offset-path","offset-position","offset-rotate","opacity","order","orphans","outline","outline-color","outline-offset","outline-style","outline-width","overflow","overflow-anchor","overflow-block","overflow-clip-margin","overflow-inline","overflow-wrap","overflow-x","overflow-y","overlay","overscroll-behavior","overscroll-behavior-block","overscroll-behavior-inline","overscroll-behavior-x","overscroll-behavior-y","padding","padding-block","padding-block-end","padding-block-start","padding-bottom","padding-inline","padding-inline-end","padding-inline-start","padding-left","padding-right","padding-top","page","page-break-after","page-break-before","page-break-inside","paint-order","pause","pause-after","pause-before","perspective","perspective-origin","place-content","place-items","place-self","pointer-events","position","position-anchor","position-visibility","print-color-adjust","quotes","r","resize","rest","rest-after","rest-before","right","rotate","row-gap","ruby-align","ruby-position","scale","scroll-behavior","scroll-margin","scroll-margin-block","scroll-margin-block-end","scroll-margin-block-start","scroll-margin-bottom","scroll-margin-inline","scroll-margin-inline-end","scroll-margin-inline-start","scroll-margin-left","scroll-margin-right","scroll-margin-top","scroll-padding","scroll-padding-block","scroll-padding-block-end","scroll-padding-block-start","scroll-padding-bottom","scroll-padding-inline","scroll-padding-inline-end","scroll-padding-inline-start","scroll-padding-left","scroll-padding-right","scroll-padding-top","scroll-snap-align","scroll-snap-stop","scroll-snap-type","scroll-timeline","scroll-timeline-axis","scroll-timeline-name","scrollbar-color","scrollbar-gutter","scrollbar-width","shape-image-threshold","shape-margin","shape-outside","shape-rendering","speak","speak-as","src","stop-color","stop-opacity","stroke","stroke-dasharray","stroke-dashoffset","stroke-linecap","stroke-linejoin","stroke-miterlimit","stroke-opacity","stroke-width","tab-size","table-layout","text-align","text-align-all","text-align-last","text-anchor","text-combine-upright","text-decoration","text-decoration-color","text-decoration-line","text-decoration-skip","text-decoration-skip-ink","text-decoration-style","text-decoration-thickness","text-emphasis","text-emphasis-color","text-emphasis-position","text-emphasis-style","text-indent","text-justify","text-orientation","text-overflow","text-rendering","text-shadow","text-size-adjust","text-transform","text-underline-offset","text-underline-position","text-wrap","text-wrap-mode","text-wrap-style","timeline-scope","top","touch-action","transform","transform-box","transform-origin","transform-style","transition","transition-behavior","transition-delay","transition-duration","transition-property","transition-timing-function","translate","unicode-bidi","user-modify","user-select","vector-effect","vertical-align","view-timeline","view-timeline-axis","view-timeline-inset","view-timeline-name","view-transition-name","visibility","voice-balance","voice-duration","voice-family","voice-pitch","voice-range","voice-rate","voice-stress","voice-volume","white-space","white-space-collapse","widows","width","will-change","word-break","word-spacing","word-wrap","writing-mode","x","y","z-index","zoom"].sort().reverse(),le=re.concat(se).sort().reverse() +;var ce="[0-9](_*[0-9])*",de=`\\.(${ce})`,ge="[0-9a-fA-F](_*[0-9a-fA-F])*",ue={ className:"number",variants:[{ -begin:`(\\b(${ge})((${ue})|\\.)?|(${ue}))[eE][+-]?(${ge})[fFdD]?\\b`},{ -begin:`\\b(${ge})((${ue})[fFdD]?\\b|\\.([fFdD]\\b)?)`},{ -begin:`(${ue})[fFdD]?\\b`},{begin:`\\b(${ge})[fFdD]\\b`},{ -begin:`\\b0[xX]((${be})\\.?|(${be})?\\.(${be}))[pP][+-]?(${ge})[fFdD]?\\b`},{ -begin:"\\b(0|[1-9](_*[0-9])*)[lL]?\\b"},{begin:`\\b0[xX](${be})[lL]?\\b`},{ +begin:`(\\b(${ce})((${de})|\\.)?|(${de}))[eE][+-]?(${ce})[fFdD]?\\b`},{ +begin:`\\b(${ce})((${de})[fFdD]?\\b|\\.([fFdD]\\b)?)`},{ +begin:`(${de})[fFdD]?\\b`},{begin:`\\b(${ce})[fFdD]\\b`},{ +begin:`\\b0[xX]((${ge})\\.?|(${ge})?\\.(${ge}))[pP][+-]?(${ce})[fFdD]?\\b`},{ +begin:"\\b(0|[1-9](_*[0-9])*)[lL]?\\b"},{begin:`\\b0[xX](${ge})[lL]?\\b`},{ begin:"\\b0(_*[0-7])*[lL]?\\b"},{begin:"\\b0[bB][01](_*[01])*[lL]?\\b"}], -relevance:0};function pe(e,n,t){return-1===t?"":e.replace(n,(a=>pe(e,n,t-1)))} -const _e="[A-Za-z$_][0-9A-Za-z$_]*",he=["as","in","of","if","for","while","finally","var","new","function","do","return","void","else","break","catch","instanceof","with","throw","case","default","try","switch","continue","typeof","delete","let","yield","const","class","debugger","async","await","static","import","from","export","extends"],fe=["true","false","null","undefined","NaN","Infinity"],Ee=["Object","Function","Boolean","Symbol","Math","Date","Number","BigInt","String","RegExp","Array","Float32Array","Float64Array","Int8Array","Uint8Array","Uint8ClampedArray","Int16Array","Int32Array","Uint16Array","Uint32Array","BigInt64Array","BigUint64Array","Set","Map","WeakSet","WeakMap","ArrayBuffer","SharedArrayBuffer","Atomics","DataView","JSON","Promise","Generator","GeneratorFunction","AsyncFunction","Reflect","Proxy","Intl","WebAssembly"],ye=["Error","EvalError","InternalError","RangeError","ReferenceError","SyntaxError","TypeError","URIError"],Ne=["setInterval","setTimeout","clearInterval","clearTimeout","require","exports","eval","isFinite","isNaN","parseFloat","parseInt","decodeURI","decodeURIComponent","encodeURI","encodeURIComponent","escape","unescape"],we=["arguments","this","super","console","window","document","localStorage","sessionStorage","module","global"],ve=[].concat(Ne,Ee,ye) -;function Oe(e){const n=e.regex,t=_e,a={begin:/<[A-Za-z0-9\\._:-]+/, +relevance:0};function be(e,n,t){return-1===t?"":e.replace(n,(a=>be(e,n,t-1)))} +const me="[A-Za-z$_][0-9A-Za-z$_]*",pe=["as","in","of","if","for","while","finally","var","new","function","do","return","void","else","break","catch","instanceof","with","throw","case","default","try","switch","continue","typeof","delete","let","yield","const","class","debugger","async","await","static","import","from","export","extends","using"],_e=["true","false","null","undefined","NaN","Infinity"],he=["Object","Function","Boolean","Symbol","Math","Date","Number","BigInt","String","RegExp","Array","Float32Array","Float64Array","Int8Array","Uint8Array","Uint8ClampedArray","Int16Array","Int32Array","Uint16Array","Uint32Array","BigInt64Array","BigUint64Array","Set","Map","WeakSet","WeakMap","ArrayBuffer","SharedArrayBuffer","Atomics","DataView","JSON","Promise","Generator","GeneratorFunction","AsyncFunction","Reflect","Proxy","Intl","WebAssembly"],fe=["Error","EvalError","InternalError","RangeError","ReferenceError","SyntaxError","TypeError","URIError"],Ee=["setInterval","setTimeout","clearInterval","clearTimeout","require","exports","eval","isFinite","isNaN","parseFloat","parseInt","decodeURI","decodeURIComponent","encodeURI","encodeURIComponent","escape","unescape"],ye=["arguments","this","super","console","window","document","localStorage","sessionStorage","module","global"],we=[].concat(Ee,he,fe) +;function ve(e){const n=e.regex,t=me,a={begin:/<[A-Za-z0-9\\._:-]+/, end:/\/[A-Za-z0-9\\._:-]+>|\/>/,isTrulyOpeningTag:(e,n)=>{ const t=e[0].length+e.index,a=e.input[t] ;if("<"===a||","===a)return void n.ignoreMatch();let i @@ -333,7 +332,7 @@ const t=e[0].length+e.index,a=e.input[t] ;return-1!==e.input.indexOf(t,n)})(e,{after:t})||n.ignoreMatch()) ;const r=e.input.substring(t) ;((i=r.match(/^\s*=/))||(i=r.match(/^\s+extends\s+/))&&0===i.index)&&n.ignoreMatch() -}},i={$pattern:_e,keyword:he,literal:fe,built_in:ve,"variable.language":we +}},i={$pattern:me,keyword:pe,literal:_e,built_in:we,"variable.language":ye },r="[0-9](_?[0-9])*",s=`\\.(${r})`,o="0|[1-9](_?[0-9])*|0[0-7]*[89][0-9]*",l={ className:"number",variants:[{ begin:`(\\b(${o})((${s})|\\.)?|(${s}))[eE][+-]?(${r})\\b`},{ @@ -342,10 +341,10 @@ begin:"\\b(0|[1-9](_?[0-9])*)n\\b"},{ begin:"\\b0[xX][0-9a-fA-F](_?[0-9a-fA-F])*n?\\b"},{ begin:"\\b0[bB][0-1](_?[0-1])*n?\\b"},{begin:"\\b0[oO][0-7](_?[0-7])*n?\\b"},{ begin:"\\b0[0-7]+n?\\b"}],relevance:0},c={className:"subst",begin:"\\$\\{", -end:"\\}",keywords:i,contains:[]},d={begin:"html`",end:"",starts:{end:"`", +end:"\\}",keywords:i,contains:[]},d={begin:".?html`",end:"",starts:{end:"`", returnEnd:!1,contains:[e.BACKSLASH_ESCAPE,c],subLanguage:"xml"}},g={ -begin:"css`",end:"",starts:{end:"`",returnEnd:!1, -contains:[e.BACKSLASH_ESCAPE,c],subLanguage:"css"}},u={begin:"gql`",end:"", +begin:".?css`",end:"",starts:{end:"`",returnEnd:!1, +contains:[e.BACKSLASH_ESCAPE,c],subLanguage:"css"}},u={begin:".?gql`",end:"", starts:{end:"`",returnEnd:!1,contains:[e.BACKSLASH_ESCAPE,c], subLanguage:"graphql"}},b={className:"string",begin:"`",end:"`", contains:[e.BACKSLASH_ESCAPE,c]},m={className:"comment", @@ -357,25 +356,26 @@ endsParent:!0,relevance:0},{begin:/(?=[^\n])\s/,relevance:0}]}] }),e.C_BLOCK_COMMENT_MODE,e.C_LINE_COMMENT_MODE] },p=[e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,d,g,u,b,{match:/\$\d+/},l] ;c.contains=p.concat({begin:/\{/,end:/\}/,keywords:i,contains:["self"].concat(p) -});const _=[].concat(m,c.contains),h=_.concat([{begin:/\(/,end:/\)/,keywords:i, -contains:["self"].concat(_)}]),f={className:"params",begin:/\(/,end:/\)/, -excludeBegin:!0,excludeEnd:!0,keywords:i,contains:h},E={variants:[{ +});const _=[].concat(m,c.contains),h=_.concat([{begin:/(\s*)\(/,end:/\)/, +keywords:i,contains:["self"].concat(_)}]),f={className:"params",begin:/(\s*)\(/, +end:/\)/,excludeBegin:!0,excludeEnd:!0,keywords:i,contains:h},E={variants:[{ match:[/class/,/\s+/,t,/\s+/,/extends/,/\s+/,n.concat(t,"(",n.concat(/\./,t),")*")], scope:{1:"keyword",3:"title.class",5:"keyword",7:"title.class.inherited"}},{ match:[/class/,/\s+/,t],scope:{1:"keyword",3:"title.class"}}]},y={relevance:0, match:n.either(/\bJSON/,/\b[A-Z][a-z]+([A-Z][a-z]*|\d)*/,/\b[A-Z]{2,}([A-Z][a-z]+|\d)+([A-Z][a-z]*)*/,/\b[A-Z]{2,}[a-z]+([A-Z][a-z]+|\d)*([A-Z][a-z]*)*/), -className:"title.class",keywords:{_:[...Ee,...ye]}},N={variants:[{ +className:"title.class",keywords:{_:[...he,...fe]}},w={variants:[{ match:[/function/,/\s+/,t,/(?=\s*\()/]},{match:[/function/,/\s*(?=\()/]}], className:{1:"keyword",3:"title.function"},label:"func.def",contains:[f], -illegal:/%/},w={ -match:n.concat(/\b/,(v=[...Ne,"super","import"],n.concat("(?!",v.join("|"),")")),t,n.lookahead(/\(/)), -className:"title.function",relevance:0};var v;const O={ +illegal:/%/},v={ +match:n.concat(/\b/,(N=[...Ee,"super","import"].map((e=>e+"\\s*\\(")), +n.concat("(?!",N.join("|"),")")),t,n.lookahead(/\s*\(/)), +className:"title.function",relevance:0};var N;const k={ begin:n.concat(/\./,n.lookahead(n.concat(t,/(?![0-9A-Za-z$_(])/))),end:t, -excludeBegin:!0,keywords:"prototype",className:"property",relevance:0},k={ +excludeBegin:!0,keywords:"prototype",className:"property",relevance:0},x={ match:[/get|set/,/\s+/,t,/(?=\()/],className:{1:"keyword",3:"title.function"}, contains:[{begin:/\(\)/},f] -},x="(\\([^()]*(\\([^()]*(\\([^()]*\\)[^()]*)*\\)[^()]*)*\\)|"+e.UNDERSCORE_IDENT_RE+")\\s*=>",M={ -match:[/const|var|let/,/\s+/,t,/\s*/,/=\s*/,/(async\s*)?/,n.lookahead(x)], +},O="(\\([^()]*(\\([^()]*(\\([^()]*\\)[^()]*)*\\)[^()]*)*\\)|"+e.UNDERSCORE_IDENT_RE+")\\s*=>",M={ +match:[/const|var|let/,/\s+/,t,/\s*/,/=\s*/,/(async\s*)?/,n.lookahead(O)], keywords:"async",className:{1:"keyword",3:"title.function"},contains:[f]} ;return{name:"JavaScript",aliases:["js","jsx","mjs","cjs"],keywords:i,exports:{ PARAMS_CONTAINS:h,CLASS_REFERENCE:y},illegal:/#(?![$_A-z])/, @@ -383,44 +383,45 @@ contains:[e.SHEBANG({label:"shebang",binary:"node",relevance:5}),{ label:"use_strict",className:"meta",relevance:10, begin:/^\s*['"]use (strict|asm)['"]/ },e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,d,g,u,b,m,{match:/\$\d+/},l,y,{ -className:"attr",begin:t+n.lookahead(":"),relevance:0},M,{ +scope:"attr",match:t+n.lookahead(":"),relevance:0},M,{ begin:"("+e.RE_STARTERS_RE+"|\\b(case|return|throw)\\b)\\s*", keywords:"return throw case",relevance:0,contains:[m,e.REGEXP_MODE,{ -className:"function",begin:x,returnBegin:!0,end:"\\s*=>",contains:[{ +className:"function",begin:O,returnBegin:!0,end:"\\s*=>",contains:[{ className:"params",variants:[{begin:e.UNDERSCORE_IDENT_RE,relevance:0},{ -className:null,begin:/\(\s*\)/,skip:!0},{begin:/\(/,end:/\)/,excludeBegin:!0, -excludeEnd:!0,keywords:i,contains:h}]}]},{begin:/,/,relevance:0},{match:/\s+/, -relevance:0},{variants:[{begin:"<>",end:""},{ +className:null,begin:/\(\s*\)/,skip:!0},{begin:/(\s*)\(/,end:/\)/, +excludeBegin:!0,excludeEnd:!0,keywords:i,contains:h}]}]},{begin:/,/,relevance:0 +},{match:/\s+/,relevance:0},{variants:[{begin:"<>",end:""},{ match:/<[A-Za-z0-9\\._:-]+\s*\/>/},{begin:a.begin, "on:begin":a.isTrulyOpeningTag,end:a.end}],subLanguage:"xml",contains:[{ -begin:a.begin,end:a.end,skip:!0,contains:["self"]}]}]},N,{ +begin:a.begin,end:a.end,skip:!0,contains:["self"]}]}]},w,{ beginKeywords:"while if switch catch for"},{ begin:"\\b(?!function)"+e.UNDERSCORE_IDENT_RE+"\\([^()]*(\\([^()]*(\\([^()]*\\)[^()]*)*\\)[^()]*)*\\)\\s*\\{", returnBegin:!0,label:"func.def",contains:[f,e.inherit(e.TITLE_MODE,{begin:t, -className:"title.function"})]},{match:/\.\.\./,relevance:0},O,{match:"\\$"+t, +className:"title.function"})]},{match:/\.\.\./,relevance:0},k,{match:"\\$"+t, relevance:0},{match:[/\bconstructor(?=\s*\()/],className:{1:"title.function"}, -contains:[f]},w,{relevance:0,match:/\b[A-Z][A-Z_0-9]+\b/, -className:"variable.constant"},E,k,{match:/\$[(.]/}]}} -const ke=e=>b(/\b/,e,/\w$/.test(e)?/\b/:/\B/),xe=["Protocol","Type"].map(ke),Me=["init","self"].map(ke),Se=["Any","Self"],Ae=["actor","any","associatedtype","async","await",/as\?/,/as!/,"as","borrowing","break","case","catch","class","consume","consuming","continue","convenience","copy","default","defer","deinit","didSet","distributed","do","dynamic","each","else","enum","extension","fallthrough",/fileprivate\(set\)/,"fileprivate","final","for","func","get","guard","if","import","indirect","infix",/init\?/,/init!/,"inout",/internal\(set\)/,"internal","in","is","isolated","nonisolated","lazy","let","macro","mutating","nonmutating",/open\(set\)/,"open","operator","optional","override","postfix","precedencegroup","prefix",/private\(set\)/,"private","protocol",/public\(set\)/,"public","repeat","required","rethrows","return","set","some","static","struct","subscript","super","switch","throws","throw",/try\?/,/try!/,"try","typealias",/unowned\(safe\)/,/unowned\(unsafe\)/,"unowned","var","weak","where","while","willSet"],Ce=["false","nil","true"],Te=["assignment","associativity","higherThan","left","lowerThan","none","right"],Re=["#colorLiteral","#column","#dsohandle","#else","#elseif","#endif","#error","#file","#fileID","#fileLiteral","#filePath","#function","#if","#imageLiteral","#keyPath","#line","#selector","#sourceLocation","#warning"],De=["abs","all","any","assert","assertionFailure","debugPrint","dump","fatalError","getVaList","isKnownUniquelyReferenced","max","min","numericCast","pointwiseMax","pointwiseMin","precondition","preconditionFailure","print","readLine","repeatElement","sequence","stride","swap","swift_unboxFromSwiftValueWithType","transcode","type","unsafeBitCast","unsafeDowncast","withExtendedLifetime","withUnsafeMutablePointer","withUnsafePointer","withVaList","withoutActuallyEscaping","zip"],Ie=m(/[/=\-+!*%<>&|^~?]/,/[\u00A1-\u00A7]/,/[\u00A9\u00AB]/,/[\u00AC\u00AE]/,/[\u00B0\u00B1]/,/[\u00B6\u00BB\u00BF\u00D7\u00F7]/,/[\u2016-\u2017]/,/[\u2020-\u2027]/,/[\u2030-\u203E]/,/[\u2041-\u2053]/,/[\u2055-\u205E]/,/[\u2190-\u23FF]/,/[\u2500-\u2775]/,/[\u2794-\u2BFF]/,/[\u2E00-\u2E7F]/,/[\u3001-\u3003]/,/[\u3008-\u3020]/,/[\u3030]/),Le=m(Ie,/[\u0300-\u036F]/,/[\u1DC0-\u1DFF]/,/[\u20D0-\u20FF]/,/[\uFE00-\uFE0F]/,/[\uFE20-\uFE2F]/),Be=b(Ie,Le,"*"),$e=m(/[a-zA-Z_]/,/[\u00A8\u00AA\u00AD\u00AF\u00B2-\u00B5\u00B7-\u00BA]/,/[\u00BC-\u00BE\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u00FF]/,/[\u0100-\u02FF\u0370-\u167F\u1681-\u180D\u180F-\u1DBF]/,/[\u1E00-\u1FFF]/,/[\u200B-\u200D\u202A-\u202E\u203F-\u2040\u2054\u2060-\u206F]/,/[\u2070-\u20CF\u2100-\u218F\u2460-\u24FF\u2776-\u2793]/,/[\u2C00-\u2DFF\u2E80-\u2FFF]/,/[\u3004-\u3007\u3021-\u302F\u3031-\u303F\u3040-\uD7FF]/,/[\uF900-\uFD3D\uFD40-\uFDCF\uFDF0-\uFE1F\uFE30-\uFE44]/,/[\uFE47-\uFEFE\uFF00-\uFFFD]/),ze=m($e,/\d/,/[\u0300-\u036F\u1DC0-\u1DFF\u20D0-\u20FF\uFE20-\uFE2F]/),Fe=b($e,ze,"*"),Ue=b(/[A-Z]/,ze,"*"),je=["attached","autoclosure",b(/convention\(/,m("swift","block","c"),/\)/),"discardableResult","dynamicCallable","dynamicMemberLookup","escaping","freestanding","frozen","GKInspectable","IBAction","IBDesignable","IBInspectable","IBOutlet","IBSegueAction","inlinable","main","nonobjc","NSApplicationMain","NSCopying","NSManaged",b(/objc\(/,Fe,/\)/),"objc","objcMembers","propertyWrapper","requires_stored_property_inits","resultBuilder","Sendable","testable","UIApplicationMain","unchecked","unknown","usableFromInline","warn_unqualified_access"],Pe=["iOS","iOSApplicationExtension","macOS","macOSApplicationExtension","macCatalyst","macCatalystApplicationExtension","watchOS","watchOSApplicationExtension","tvOS","tvOSApplicationExtension","swift"] -;var Ke=Object.freeze({__proto__:null,grmr_bash:e=>{const n=e.regex,t={},a={ +contains:[f]},v,{relevance:0,match:/\b[A-Z][A-Z_0-9]+\b/, +className:"variable.constant"},E,x,{match:/\$[(.]/}]}} +const Ne=e=>b(/\b/,e,/\w$/.test(e)?/\b/:/\B/),ke=["Protocol","Type"].map(Ne),xe=["init","self"].map(Ne),Oe=["Any","Self"],Me=["actor","any","associatedtype","async","await",/as\?/,/as!/,"as","borrowing","break","case","catch","class","consume","consuming","continue","convenience","copy","default","defer","deinit","didSet","distributed","do","dynamic","each","else","enum","extension","fallthrough",/fileprivate\(set\)/,"fileprivate","final","for","func","get","guard","if","import","indirect","infix",/init\?/,/init!/,"inout",/internal\(set\)/,"internal","in","is","isolated","nonisolated","lazy","let","macro","mutating","nonmutating",/open\(set\)/,"open","operator","optional","override","package","postfix","precedencegroup","prefix",/private\(set\)/,"private","protocol",/public\(set\)/,"public","repeat","required","rethrows","return","set","some","static","struct","subscript","super","switch","throws","throw",/try\?/,/try!/,"try","typealias",/unowned\(safe\)/,/unowned\(unsafe\)/,"unowned","var","weak","where","while","willSet"],Ae=["false","nil","true"],Se=["assignment","associativity","higherThan","left","lowerThan","none","right"],Ce=["#colorLiteral","#column","#dsohandle","#else","#elseif","#endif","#error","#file","#fileID","#fileLiteral","#filePath","#function","#if","#imageLiteral","#keyPath","#line","#selector","#sourceLocation","#warning"],Te=["abs","all","any","assert","assertionFailure","debugPrint","dump","fatalError","getVaList","isKnownUniquelyReferenced","max","min","numericCast","pointwiseMax","pointwiseMin","precondition","preconditionFailure","print","readLine","repeatElement","sequence","stride","swap","swift_unboxFromSwiftValueWithType","transcode","type","unsafeBitCast","unsafeDowncast","withExtendedLifetime","withUnsafeMutablePointer","withUnsafePointer","withVaList","withoutActuallyEscaping","zip"],Re=m(/[/=\-+!*%<>&|^~?]/,/[\u00A1-\u00A7]/,/[\u00A9\u00AB]/,/[\u00AC\u00AE]/,/[\u00B0\u00B1]/,/[\u00B6\u00BB\u00BF\u00D7\u00F7]/,/[\u2016-\u2017]/,/[\u2020-\u2027]/,/[\u2030-\u203E]/,/[\u2041-\u2053]/,/[\u2055-\u205E]/,/[\u2190-\u23FF]/,/[\u2500-\u2775]/,/[\u2794-\u2BFF]/,/[\u2E00-\u2E7F]/,/[\u3001-\u3003]/,/[\u3008-\u3020]/,/[\u3030]/),De=m(Re,/[\u0300-\u036F]/,/[\u1DC0-\u1DFF]/,/[\u20D0-\u20FF]/,/[\uFE00-\uFE0F]/,/[\uFE20-\uFE2F]/),Ie=b(Re,De,"*"),Le=m(/[a-zA-Z_]/,/[\u00A8\u00AA\u00AD\u00AF\u00B2-\u00B5\u00B7-\u00BA]/,/[\u00BC-\u00BE\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u00FF]/,/[\u0100-\u02FF\u0370-\u167F\u1681-\u180D\u180F-\u1DBF]/,/[\u1E00-\u1FFF]/,/[\u200B-\u200D\u202A-\u202E\u203F-\u2040\u2054\u2060-\u206F]/,/[\u2070-\u20CF\u2100-\u218F\u2460-\u24FF\u2776-\u2793]/,/[\u2C00-\u2DFF\u2E80-\u2FFF]/,/[\u3004-\u3007\u3021-\u302F\u3031-\u303F\u3040-\uD7FF]/,/[\uF900-\uFD3D\uFD40-\uFDCF\uFDF0-\uFE1F\uFE30-\uFE44]/,/[\uFE47-\uFEFE\uFF00-\uFFFD]/),Be=m(Le,/\d/,/[\u0300-\u036F\u1DC0-\u1DFF\u20D0-\u20FF\uFE20-\uFE2F]/),$e=b(Le,Be,"*"),Fe=b(/[A-Z]/,Be,"*"),ze=["attached","autoclosure",b(/convention\(/,m("swift","block","c"),/\)/),"discardableResult","dynamicCallable","dynamicMemberLookup","escaping","freestanding","frozen","GKInspectable","IBAction","IBDesignable","IBInspectable","IBOutlet","IBSegueAction","inlinable","main","nonobjc","NSApplicationMain","NSCopying","NSManaged",b(/objc\(/,$e,/\)/),"objc","objcMembers","propertyWrapper","requires_stored_property_inits","resultBuilder","Sendable","testable","UIApplicationMain","unchecked","unknown","usableFromInline","warn_unqualified_access"],je=["iOS","iOSApplicationExtension","macOS","macOSApplicationExtension","macCatalyst","macCatalystApplicationExtension","watchOS","watchOSApplicationExtension","tvOS","tvOSApplicationExtension","swift"] +;var Ue=Object.freeze({__proto__:null,grmr_bash:e=>{const n=e.regex,t={},a={ begin:/\$\{/,end:/\}/,contains:["self",{begin:/:-/,contains:[t]}]} ;Object.assign(t,{className:"variable",variants:[{ begin:n.concat(/\$[\w\d#@][\w\d_]*/,"(?![\\w\\d])(?![$])")},a]});const i={ -className:"subst",begin:/\$\(/,end:/\)/,contains:[e.BACKSLASH_ESCAPE]},r={ +className:"subst",begin:/\$\(/,end:/\)/,contains:[e.BACKSLASH_ESCAPE] +},r=e.inherit(e.COMMENT(),{match:[/(^|\s)/,/#.*$/],scope:{2:"comment"}}),s={ begin:/<<-?\s*(?=\w+)/,starts:{contains:[e.END_SAME_AS_BEGIN({begin:/(\w+)/, -end:/(\w+)/,className:"string"})]}},s={className:"string",begin:/"/,end:/"/, -contains:[e.BACKSLASH_ESCAPE,t,i]};i.contains.push(s);const o={begin:/\$?\(\(/, +end:/(\w+)/,className:"string"})]}},o={className:"string",begin:/"/,end:/"/, +contains:[e.BACKSLASH_ESCAPE,t,i]};i.contains.push(o);const l={begin:/\$?\(\(/, end:/\)\)/,contains:[{begin:/\d+#[0-9a-f]+/,className:"number"},e.NUMBER_MODE,t] -},l=e.SHEBANG({binary:"(fish|bash|zsh|sh|csh|ksh|tcsh|dash|scsh)",relevance:10 -}),c={className:"function",begin:/\w[\w\d_]*\s*\(\s*\)\s*\{/,returnBegin:!0, +},c=e.SHEBANG({binary:"(fish|bash|zsh|sh|csh|ksh|tcsh|dash|scsh)",relevance:10 +}),d={className:"function",begin:/\w[\w\d_]*\s*\(\s*\)\s*\{/,returnBegin:!0, contains:[e.inherit(e.TITLE_MODE,{begin:/\w[\w\d_]*/})],relevance:0};return{ -name:"Bash",aliases:["sh"],keywords:{$pattern:/\b[a-z][a-z0-9._-]+\b/, -keyword:["if","then","else","elif","fi","for","while","until","in","do","done","case","esac","function","select"], +name:"Bash",aliases:["sh","zsh"],keywords:{$pattern:/\b[a-z][a-z0-9._-]+\b/, +keyword:["if","then","else","elif","fi","time","for","while","until","in","do","done","case","esac","coproc","function","select"], literal:["true","false"], -built_in:["break","cd","continue","eval","exec","exit","export","getopts","hash","pwd","readonly","return","shift","test","times","trap","umask","unset","alias","bind","builtin","caller","command","declare","echo","enable","help","let","local","logout","mapfile","printf","read","readarray","source","type","typeset","ulimit","unalias","set","shopt","autoload","bg","bindkey","bye","cap","chdir","clone","comparguments","compcall","compctl","compdescribe","compfiles","compgroups","compquote","comptags","comptry","compvalues","dirs","disable","disown","echotc","echoti","emulate","fc","fg","float","functions","getcap","getln","history","integer","jobs","kill","limit","log","noglob","popd","print","pushd","pushln","rehash","sched","setcap","setopt","stat","suspend","ttyctl","unfunction","unhash","unlimit","unsetopt","vared","wait","whence","where","which","zcompile","zformat","zftp","zle","zmodload","zparseopts","zprof","zpty","zregexparse","zsocket","zstyle","ztcp","chcon","chgrp","chown","chmod","cp","dd","df","dir","dircolors","ln","ls","mkdir","mkfifo","mknod","mktemp","mv","realpath","rm","rmdir","shred","sync","touch","truncate","vdir","b2sum","base32","base64","cat","cksum","comm","csplit","cut","expand","fmt","fold","head","join","md5sum","nl","numfmt","od","paste","ptx","pr","sha1sum","sha224sum","sha256sum","sha384sum","sha512sum","shuf","sort","split","sum","tac","tail","tr","tsort","unexpand","uniq","wc","arch","basename","chroot","date","dirname","du","echo","env","expr","factor","groups","hostid","id","link","logname","nice","nohup","nproc","pathchk","pinky","printenv","printf","pwd","readlink","runcon","seq","sleep","stat","stdbuf","stty","tee","test","timeout","tty","uname","unlink","uptime","users","who","whoami","yes"] -},contains:[l,e.SHEBANG(),c,o,e.HASH_COMMENT_MODE,r,{match:/(\/[a-z._-]+)+/},s,{ -match:/\\"/},{className:"string",begin:/'/,end:/'/},{match:/\\'/},t]}}, -grmr_c:e=>{const n=e.regex,t=e.COMMENT("//","$",{contains:[{begin:/\\\n/}] +built_in:["break","cd","continue","eval","exec","exit","export","getopts","hash","pwd","readonly","return","shift","test","times","trap","umask","unset","alias","bind","builtin","caller","command","declare","echo","enable","help","let","local","logout","mapfile","printf","read","readarray","source","sudo","type","typeset","ulimit","unalias","set","shopt","autoload","bg","bindkey","bye","cap","chdir","clone","comparguments","compcall","compctl","compdescribe","compfiles","compgroups","compquote","comptags","comptry","compvalues","dirs","disable","disown","echotc","echoti","emulate","fc","fg","float","functions","getcap","getln","history","integer","jobs","kill","limit","log","noglob","popd","print","pushd","pushln","rehash","sched","setcap","setopt","stat","suspend","ttyctl","unfunction","unhash","unlimit","unsetopt","vared","wait","whence","where","which","zcompile","zformat","zftp","zle","zmodload","zparseopts","zprof","zpty","zregexparse","zsocket","zstyle","ztcp","chcon","chgrp","chown","chmod","cp","dd","df","dir","dircolors","ln","ls","mkdir","mkfifo","mknod","mktemp","mv","realpath","rm","rmdir","shred","sync","touch","truncate","vdir","b2sum","base32","base64","cat","cksum","comm","csplit","cut","expand","fmt","fold","head","join","md5sum","nl","numfmt","od","paste","ptx","pr","sha1sum","sha224sum","sha256sum","sha384sum","sha512sum","shuf","sort","split","sum","tac","tail","tr","tsort","unexpand","uniq","wc","arch","basename","chroot","date","dirname","du","echo","env","expr","factor","groups","hostid","id","link","logname","nice","nohup","nproc","pathchk","pinky","printenv","printf","pwd","readlink","runcon","seq","sleep","stat","stdbuf","stty","tee","test","timeout","tty","uname","unlink","uptime","users","who","whoami","yes"] +},contains:[c,e.SHEBANG(),d,l,r,s,{match:/(\/[a-z._-]+)+/},o,{match:/\\"/},{ +className:"string",begin:/'/,end:/'/},{match:/\\'/},t]}},grmr_c:e=>{ +const n=e.regex,t=e.COMMENT("//","$",{contains:[{begin:/\\\n/}] }),a="decltype\\(auto\\)",i="[a-zA-Z_]\\w*::",r="("+a+"|"+n.optional(i)+"[a-zA-Z_]\\w*"+n.optional("<[^<>]+>")+")",s={ className:"type",variants:[{begin:"\\b[a-z\\d_]*_t\\b"},{ match:/\batomic_[a-z]{3,6}\b/}]},o={className:"string",variants:[{ @@ -428,18 +429,19 @@ begin:'(u8?|U|L)?"',end:'"',illegal:"\\n",contains:[e.BACKSLASH_ESCAPE]},{ begin:"(u8?|U|L)?'(\\\\(x[0-9A-Fa-f]{2}|u[0-9A-Fa-f]{4,8}|[0-7]{3}|\\S)|.)", end:"'",illegal:"."},e.END_SAME_AS_BEGIN({ begin:/(?:u8?|U|L)?R"([^()\\ ]{0,16})\(/,end:/\)([^()\\ ]{0,16})"/})]},l={ -className:"number",variants:[{begin:"\\b(0b[01']+)"},{ -begin:"(-?)\\b([\\d']+(\\.[\\d']*)?|\\.[\\d']+)((ll|LL|l|L)(u|U)?|(u|U)(ll|LL|l|L)?|f|F|b|B)" +className:"number",variants:[{match:/\b(0b[01']+)/},{ +match:/(-?)\b([\d']+(\.[\d']*)?|\.[\d']+)((ll|LL|l|L)(u|U)?|(u|U)(ll|LL|l|L)?|f|F|b|B)/ },{ -begin:"(-?)(\\b0[xX][a-fA-F0-9']+|(\\b[\\d']+(\\.[\\d']*)?|\\.[\\d']+)([eE][-+]?[\\d']+)?)" -}],relevance:0},c={className:"meta",begin:/#\s*[a-z]+\b/,end:/$/,keywords:{ -keyword:"if else elif endif define undef warning error line pragma _Pragma ifdef ifndef include" +match:/(-?)\b(0[xX][a-fA-F0-9]+(?:'[a-fA-F0-9]+)*(?:\.[a-fA-F0-9]*(?:'[a-fA-F0-9]*)*)?(?:[pP][-+]?[0-9]+)?(l|L)?(u|U)?)/ +},{match:/(-?)\b\d+(?:'\d+)*(?:\.\d*(?:'\d*)*)?(?:[eE][-+]?\d+)?/}],relevance:0 +},c={className:"meta",begin:/#\s*[a-z]+\b/,end:/$/,keywords:{ +keyword:"if else elif endif define undef warning error line pragma _Pragma ifdef ifndef elifdef elifndef include" },contains:[{begin:/\\\n/,relevance:0},e.inherit(o,{className:"string"}),{ className:"string",begin:/<.*?>/},t,e.C_BLOCK_COMMENT_MODE]},d={ className:"title",begin:n.optional(i)+e.IDENT_RE,relevance:0 },g=n.optional(i)+e.IDENT_RE+"\\s*\\(",u={ -keyword:["asm","auto","break","case","continue","default","do","else","enum","extern","for","fortran","goto","if","inline","register","restrict","return","sizeof","struct","switch","typedef","union","volatile","while","_Alignas","_Alignof","_Atomic","_Generic","_Noreturn","_Static_assert","_Thread_local","alignas","alignof","noreturn","static_assert","thread_local","_Pragma"], -type:["float","double","signed","unsigned","int","short","long","char","void","_Bool","_Complex","_Imaginary","_Decimal32","_Decimal64","_Decimal128","const","static","complex","bool","imaginary"], +keyword:["asm","auto","break","case","continue","default","do","else","enum","extern","for","fortran","goto","if","inline","register","restrict","return","sizeof","typeof","typeof_unqual","struct","switch","typedef","union","volatile","while","_Alignas","_Alignof","_Atomic","_Generic","_Noreturn","_Static_assert","_Thread_local","alignas","alignof","noreturn","static_assert","thread_local","_Pragma"], +type:["float","double","signed","unsigned","int","short","long","char","void","_Bool","_BitInt","_Complex","_Imaginary","_Decimal32","_Decimal64","_Decimal96","_Decimal128","_Decimal64x","_Decimal128x","_Float16","_Float32","_Float64","_Float128","_Float32x","_Float64x","_Float128x","const","static","constexpr","complex","bool","imaginary"], literal:"true false NULL", built_in:"std string wstring cin cout cerr clog stdin stdout stderr stringstream istringstream ostringstream auto_ptr deque list queue stack vector map set pair bitset multiset multimap unordered_set unordered_map unordered_multiset unordered_multimap priority_queue make_pair array shared_ptr abort terminate abs acos asin atan2 atan calloc ceil cosh cos exit exp fabs floor fmod fprintf fputs free frexp fscanf future isalnum isalpha iscntrl isdigit isgraph islower isprint ispunct isspace isupper isxdigit tolower toupper labs ldexp log10 log malloc realloc memchr memcmp memcpy memset modf pow printf putchar puts scanf sinh sin snprintf sprintf sqrt sscanf strcat strchr strcmp strcpy strcspn strlen strncat strncmp strncpy strpbrk strrchr strspn strstr tanh tan vfprintf vprintf vsprintf endl initializer_list unique_ptr" },b=[c,s,t,e.C_BLOCK_COMMENT_MODE,l,o],m={variants:[{begin:/=/,end:/;/},{ @@ -465,10 +467,10 @@ begin:'(u8?|U|L)?"',end:'"',illegal:"\\n",contains:[e.BACKSLASH_ESCAPE]},{ begin:"(u8?|U|L)?'(\\\\(x[0-9A-Fa-f]{2}|u[0-9A-Fa-f]{4,8}|[0-7]{3}|\\S)|.)", end:"'",illegal:"."},e.END_SAME_AS_BEGIN({ begin:/(?:u8?|U|L)?R"([^()\\ ]{0,16})\(/,end:/\)([^()\\ ]{0,16})"/})]},l={ -className:"number",variants:[{begin:"\\b(0b[01']+)"},{ -begin:"(-?)\\b([\\d']+(\\.[\\d']*)?|\\.[\\d']+)((ll|LL|l|L)(u|U)?|(u|U)(ll|LL|l|L)?|f|F|b|B)" +className:"number",variants:[{ +begin:"[+-]?(?:(?:[0-9](?:'?[0-9])*\\.(?:[0-9](?:'?[0-9])*)?|\\.[0-9](?:'?[0-9])*)(?:[Ee][+-]?[0-9](?:'?[0-9])*)?|[0-9](?:'?[0-9])*[Ee][+-]?[0-9](?:'?[0-9])*|0[Xx](?:[0-9A-Fa-f](?:'?[0-9A-Fa-f])*(?:\\.(?:[0-9A-Fa-f](?:'?[0-9A-Fa-f])*)?)?|\\.[0-9A-Fa-f](?:'?[0-9A-Fa-f])*)[Pp][+-]?[0-9](?:'?[0-9])*)(?:[Ff](?:16|32|64|128)?|(BF|bf)16|[Ll]|)" },{ -begin:"(-?)(\\b0[xX][a-fA-F0-9']+|(\\b[\\d']+(\\.[\\d']*)?|\\.[\\d']+)([eE][-+]?[\\d']+)?)" +begin:"[+-]?\\b(?:0[Bb][01](?:'?[01])*|0[Xx][0-9A-Fa-f](?:'?[0-9A-Fa-f])*|0(?:'?[0-7])*|[1-9](?:'?[0-9])*)(?:[Uu](?:LL?|ll?)|[Uu][Zz]?|(?:LL?|ll?)[Uu]?|[Zz][Uu]|)" }],relevance:0},c={className:"meta",begin:/#\s*[a-z]+\b/,end:/$/,keywords:{ keyword:"if else elif endif define undef warning error line pragma _Pragma ifdef ifndef include" },contains:[{begin:/\\\n/,relevance:0},e.inherit(o,{className:"string"}),{ @@ -478,7 +480,7 @@ className:"title",begin:n.optional(i)+e.IDENT_RE,relevance:0 type:["bool","char","char16_t","char32_t","char8_t","double","float","int","long","short","void","wchar_t","unsigned","signed","const","static"], keyword:["alignas","alignof","and","and_eq","asm","atomic_cancel","atomic_commit","atomic_noexcept","auto","bitand","bitor","break","case","catch","class","co_await","co_return","co_yield","compl","concept","const_cast|10","consteval","constexpr","constinit","continue","decltype","default","delete","do","dynamic_cast|10","else","enum","explicit","export","extern","false","final","for","friend","goto","if","import","inline","module","mutable","namespace","new","noexcept","not","not_eq","nullptr","operator","or","or_eq","override","private","protected","public","reflexpr","register","reinterpret_cast|10","requires","return","sizeof","static_assert","static_cast|10","struct","switch","synchronized","template","this","thread_local","throw","transaction_safe","transaction_safe_dynamic","true","try","typedef","typeid","typename","union","using","virtual","volatile","while","xor","xor_eq"], literal:["NULL","false","nullopt","nullptr","true"],built_in:["_Pragma"], -_type_hints:["any","auto_ptr","barrier","binary_semaphore","bitset","complex","condition_variable","condition_variable_any","counting_semaphore","deque","false_type","future","imaginary","initializer_list","istringstream","jthread","latch","lock_guard","multimap","multiset","mutex","optional","ostringstream","packaged_task","pair","promise","priority_queue","queue","recursive_mutex","recursive_timed_mutex","scoped_lock","set","shared_future","shared_lock","shared_mutex","shared_timed_mutex","shared_ptr","stack","string_view","stringstream","timed_mutex","thread","true_type","tuple","unique_lock","unique_ptr","unordered_map","unordered_multimap","unordered_multiset","unordered_set","variant","vector","weak_ptr","wstring","wstring_view"] +_type_hints:["any","auto_ptr","barrier","binary_semaphore","bitset","complex","condition_variable","condition_variable_any","counting_semaphore","deque","false_type","flat_map","flat_set","future","imaginary","initializer_list","istringstream","jthread","latch","lock_guard","multimap","multiset","mutex","optional","ostringstream","packaged_task","pair","promise","priority_queue","queue","recursive_mutex","recursive_timed_mutex","scoped_lock","set","shared_future","shared_lock","shared_mutex","shared_timed_mutex","shared_ptr","stack","string_view","stringstream","timed_mutex","thread","true_type","tuple","unique_lock","unique_ptr","unordered_map","unordered_multimap","unordered_multiset","unordered_set","variant","vector","weak_ptr","wstring","wstring_view"] },b={className:"function.dispatch",relevance:0,keywords:{ _hint:["abort","abs","acos","apply","as_const","asin","atan","atan2","calloc","ceil","cerr","cin","clog","cos","cosh","cout","declval","endl","exchange","exit","exp","fabs","floor","fmod","forward","fprintf","fputs","free","frexp","fscanf","future","invoke","isalnum","isalpha","iscntrl","isdigit","isgraph","islower","isprint","ispunct","isspace","isupper","isxdigit","labs","launder","ldexp","log","log10","make_pair","make_shared","make_shared_for_overwrite","make_tuple","make_unique","malloc","memchr","memcmp","memcpy","memset","modf","move","pow","printf","putchar","puts","realloc","scanf","sin","sinh","snprintf","sprintf","sqrt","sscanf","std","stderr","stdin","stdout","strcat","strchr","strcmp","strcpy","strcspn","strlen","strncat","strncmp","strncpy","strpbrk","strrchr","strspn","strstr","swap","tan","tanh","terminate","to_underlying","tolower","toupper","vfprintf","visit","vprintf","vsprintf"] }, @@ -498,11 +500,11 @@ relevance:0,contains:["self",t,e.C_BLOCK_COMMENT_MODE,o,l,s]}] aliases:["cc","c++","h++","hpp","hh","hxx","cxx"],keywords:u,illegal:"",keywords:u,contains:["self",s]},{begin:e.IDENT_RE+"::",keywords:u},{ match:[/\b(?:enum(?:\s+(?:class|struct))?|class|struct|union)/,/\s+/,/\w+/], className:{1:"keyword",3:"title.class"}}])}},grmr_csharp:e=>{const n={ -keyword:["abstract","as","base","break","case","catch","class","const","continue","do","else","event","explicit","extern","finally","fixed","for","foreach","goto","if","implicit","in","interface","internal","is","lock","namespace","new","operator","out","override","params","private","protected","public","readonly","record","ref","return","scoped","sealed","sizeof","stackalloc","static","struct","switch","this","throw","try","typeof","unchecked","unsafe","using","virtual","void","volatile","while"].concat(["add","alias","and","ascending","async","await","by","descending","equals","from","get","global","group","init","into","join","let","nameof","not","notnull","on","or","orderby","partial","remove","select","set","unmanaged","value|0","var","when","where","with","yield"]), +keyword:["abstract","as","base","break","case","catch","class","const","continue","do","else","event","explicit","extern","finally","fixed","for","foreach","goto","if","implicit","in","interface","internal","is","lock","namespace","new","operator","out","override","params","private","protected","public","readonly","record","ref","return","scoped","sealed","sizeof","stackalloc","static","struct","switch","this","throw","try","typeof","unchecked","unsafe","using","virtual","void","volatile","while"].concat(["add","alias","and","ascending","args","async","await","by","descending","dynamic","equals","file","from","get","global","group","init","into","join","let","nameof","not","notnull","on","or","orderby","partial","record","remove","required","scoped","select","set","unmanaged","value|0","var","when","where","with","yield"]), built_in:["bool","byte","char","decimal","delegate","double","dynamic","enum","float","int","long","nint","nuint","object","sbyte","short","string","ulong","uint","ushort"], literal:["default","false","null","true"]},t=e.inherit(e.TITLE_MODE,{ begin:"[a-zA-Z](\\.?\\w)*"}),a={className:"number",variants:[{ @@ -518,8 +520,10 @@ begin:/\{\{/},{begin:/\}\}/},{begin:'""'},s]},d=e.inherit(c,{illegal:/\n/, contains:[{begin:/\{\{/},{begin:/\}\}/},{begin:'""'},o]}) ;s.contains=[c,l,i,e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,a,e.C_BLOCK_COMMENT_MODE], o.contains=[d,l,r,e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,a,e.inherit(e.C_BLOCK_COMMENT_MODE,{ -illegal:/\n/})];const g={variants:[c,l,i,e.APOS_STRING_MODE,e.QUOTE_STRING_MODE] -},u={begin:"<",end:">",contains:[{beginKeywords:"in out"},t] +illegal:/\n/})];const g={variants:[{className:"string", +begin:/"""("*)(?!")(.|\n)*?"""\1/,relevance:1 +},c,l,i,e.APOS_STRING_MODE,e.QUOTE_STRING_MODE]},u={begin:"<",end:">", +contains:[{beginKeywords:"in out"},t] },b=e.IDENT_RE+"(<"+e.IDENT_RE+"(\\s*,\\s*"+e.IDENT_RE+")*>)?(\\[\\])?",m={ begin:"@"+e.IDENT_RE,relevance:0};return{name:"C#",aliases:["cs","c#"], keywords:n,illegal:/::/,contains:[e.COMMENT("///","$",{returnBegin:!0, @@ -546,15 +550,15 @@ contains:[e.TITLE_MODE,u],relevance:0},{match:/\(\)/},{className:"params", begin:/\(/,end:/\)/,excludeBegin:!0,excludeEnd:!0,keywords:n,relevance:0, contains:[g,a,e.C_BLOCK_COMMENT_MODE] },e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE]},m]}},grmr_css:e=>{ -const n=e.regex,t=ie(e),a=[e.APOS_STRING_MODE,e.QUOTE_STRING_MODE];return{ +const n=e.regex,t=te(e),a=[e.APOS_STRING_MODE,e.QUOTE_STRING_MODE];return{ name:"CSS",case_insensitive:!0,illegal:/[=|'\$]/,keywords:{ keyframePosition:"from to"},classNameAliases:{keyframePosition:"selector-tag"}, contains:[t.BLOCK_COMMENT,{begin:/-(webkit|moz|ms|o)-(?=[a-z])/ },t.CSS_NUMBER_MODE,{className:"selector-id",begin:/#[A-Za-z0-9_-]+/,relevance:0 },{className:"selector-class",begin:"\\.[a-zA-Z-][a-zA-Z0-9_-]*",relevance:0 },t.ATTRIBUTE_SELECTOR_MODE,{className:"selector-pseudo",variants:[{ -begin:":("+oe.join("|")+")"},{begin:":(:)?("+le.join("|")+")"}] -},t.CSS_VARIABLE,{className:"attribute",begin:"\\b("+ce.join("|")+")\\b"},{ +begin:":("+re.join("|")+")"},{begin:":(:)?("+se.join("|")+")"}] +},t.CSS_VARIABLE,{className:"attribute",begin:"\\b("+oe.join("|")+")\\b"},{ begin:/:/,end:/[;}{]/, contains:[t.BLOCK_COMMENT,t.HEXCOLOR,t.IMPORTANT,t.CSS_NUMBER_MODE,...a,{ begin:/(url|data-uri)\(/,end:/\)/,relevance:0,keywords:{built_in:"url data-uri" @@ -562,9 +566,9 @@ begin:/(url|data-uri)\(/,end:/\)/,relevance:0,keywords:{built_in:"url data-uri" excludeEnd:!0}]},t.FUNCTION_DISPATCH]},{begin:n.lookahead(/@/),end:"[{;]", relevance:0,illegal:/:/,contains:[{className:"keyword",begin:/@-?\w[\w]*(-\w+)*/ },{begin:/\s/,endsWithParent:!0,excludeEnd:!0,relevance:0,keywords:{ -$pattern:/[a-z-]+/,keyword:"and or not only",attribute:se.join(" ")},contains:[{ +$pattern:/[a-z-]+/,keyword:"and or not only",attribute:ie.join(" ")},contains:[{ begin:/[a-z-]+(?=:)/,className:"attribute"},...a,t.CSS_NUMBER_MODE]}]},{ -className:"selector-tag",begin:"\\b("+re.join("|")+")\\b"}]}},grmr_diff:e=>{ +className:"selector-tag",begin:"\\b("+ae.join("|")+")\\b"}]}},grmr_diff:e=>{ const n=e.regex;return{name:"Diff",aliases:["patch"],contains:[{ className:"meta",relevance:10, match:n.either(/^@@ +-\d+,\d+ +\+\d+,\d+ +@@/,/^\*\*\* +\d+,\d+ +\*\*\*\*$/,/^--- +\d+,\d+ +----$/) @@ -580,12 +584,18 @@ built_in:["append","cap","close","complex","copy","imag","len","make","new","pan };return{name:"Go",aliases:["golang"],keywords:n,illegal:"{const n=e.regex;return{name:"GraphQL",aliases:["gql"], -case_insensitive:!0,disableAutodetect:!1,keywords:{ +className:"number",variants:[{ +match:/-?\b0[xX]\.[a-fA-F0-9](_?[a-fA-F0-9])*[pP][+-]?\d(_?\d)*i?/,relevance:0 +},{ +match:/-?\b0[xX](_?[a-fA-F0-9])+((\.([a-fA-F0-9](_?[a-fA-F0-9])*)?)?[pP][+-]?\d(_?\d)*)?i?/, +relevance:0},{match:/-?\b0[oO](_?[0-7])*i?/,relevance:0},{ +match:/-?\.\d(_?\d)*([eE][+-]?\d(_?\d)*)?i?/,relevance:0},{ +match:/-?\b\d(_?\d)*(\.(\d(_?\d)*)?)?([eE][+-]?\d(_?\d)*)?i?/,relevance:0}]},{ +begin:/:=/},{className:"function",beginKeywords:"func",end:"\\s*(\\{|$)", +excludeEnd:!0,contains:[e.TITLE_MODE,{className:"params",begin:/\(/,end:/\)/, +endsParent:!0,keywords:n,illegal:/["']/}]}]}},grmr_graphql:e=>{const n=e.regex +;return{name:"GraphQL",aliases:["gql"],case_insensitive:!0,disableAutodetect:!1, +keywords:{ keyword:["query","mutation","subscription","type","input","schema","directive","interface","union","scalar","fragment","enum","on"], literal:["true","false","null"]}, contains:[e.HASH_COMMENT_MODE,e.QUOTE_STRING_MODE,e.NUMBER_MODE,{ @@ -607,8 +617,8 @@ name:"TOML, also INI",aliases:["toml"],case_insensitive:!0,illegal:/\S/, contains:[a,{className:"section",begin:/\[+/,end:/\]+/},{ begin:n.concat(l,"(\\s*\\.\\s*",l,")*",n.lookahead(/\s*=\s*[^#\s]/)), className:"attr",starts:{end:/$/,contains:[a,o,r,i,s,t]}}]}},grmr_java:e=>{ -const n=e.regex,t="[\xc0-\u02b8a-zA-Z_$][\xc0-\u02b8a-zA-Z_$0-9]*",a=t+pe("(?:<"+t+"~~~(?:\\s*,\\s*"+t+"~~~)*>)?",/~~~/g,2),i={ -keyword:["synchronized","abstract","private","var","static","if","const ","for","while","strictfp","finally","protected","import","native","final","void","enum","else","break","transient","catch","instanceof","volatile","case","assert","package","default","public","try","switch","continue","throws","protected","public","private","module","requires","exports","do","sealed","yield","permits"], +const n=e.regex,t="[\xc0-\u02b8a-zA-Z_$][\xc0-\u02b8a-zA-Z_$0-9]*",a=t+be("(?:<"+t+"~~~(?:\\s*,\\s*"+t+"~~~)*>)?",/~~~/g,2),i={ +keyword:["synchronized","abstract","private","var","static","if","const ","for","while","strictfp","finally","protected","import","native","final","void","enum","else","break","transient","catch","instanceof","volatile","case","assert","package","default","public","try","switch","continue","throws","protected","public","private","module","requires","exports","do","sealed","yield","permits","goto","when"], literal:["false","true","null"], type:["char","boolean","long","float","int","byte","short","double"], built_in:["super","this"]},r={className:"meta",begin:"@"+t,contains:[{ @@ -630,12 +640,12 @@ beginKeywords:"new throw return else",relevance:0},{ begin:["(?:"+a+"\\s+)",e.UNDERSCORE_IDENT_RE,/\s*(?=\()/],className:{ 2:"title.function"},keywords:i,contains:[{className:"params",begin:/\(/, end:/\)/,keywords:i,relevance:0, -contains:[r,e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,me,e.C_BLOCK_COMMENT_MODE] -},e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE]},me,r]}},grmr_javascript:Oe, +contains:[r,e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,ue,e.C_BLOCK_COMMENT_MODE] +},e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE]},ue,r]}},grmr_javascript:ve, grmr_json:e=>{const n=["true","false","null"],t={scope:"literal", -beginKeywords:n.join(" ")};return{name:"JSON",keywords:{literal:n},contains:[{ -className:"attr",begin:/"(\\.|[^\\"\r\n])*"(?=\s*:)/,relevance:1.01},{ -match:/[{}[\],:]/,className:"punctuation",relevance:0 +beginKeywords:n.join(" ")};return{name:"JSON",aliases:["jsonc"],keywords:{ +literal:n},contains:[{className:"attr",begin:/"(\\.|[^\\"\r\n])*"(?=\s*:)/, +relevance:1.01},{match:/[{}[\],:]/,className:"punctuation",relevance:0 },e.QUOTE_STRING_MODE,t,e.C_NUMBER_MODE,e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE], illegal:"\\S"}},grmr_kotlin:e=>{const n={ keyword:"abstract as val var vararg get set class object open private protected public noinline crossinline dynamic final enum if else do while for when throw try catch finally import package is in fun override companion reified inline lateinit init interface annotation data sealed internal infix operator out by constructor super tailrec where const inner suspend typealias external expect actual", @@ -650,7 +660,7 @@ className:"meta", begin:"@(?:file|property|field|get|set|receiver|param|setparam|delegate)\\s*:(?:\\s*"+e.UNDERSCORE_IDENT_RE+")?" },o={className:"meta",begin:"@"+e.UNDERSCORE_IDENT_RE,contains:[{begin:/\(/, end:/\)/,contains:[e.inherit(r,{className:"string"}),"self"]}] -},l=me,c=e.COMMENT("/\\*","\\*/",{contains:[e.C_BLOCK_COMMENT_MODE]}),d={ +},l=ue,c=e.COMMENT("/\\*","\\*/",{contains:[e.C_BLOCK_COMMENT_MODE]}),d={ variants:[{className:"type",begin:e.UNDERSCORE_IDENT_RE},{begin:/\(/,end:/\)/, contains:[]}]},g=d;return g.variants[1].contains=[d],d.variants[1].contains=[g], {name:"Kotlin",aliases:["kt","kts"],keywords:n, @@ -673,10 +683,10 @@ beginKeywords:"public protected internal private constructor" excludeEnd:!0,relevance:0},{className:"type",begin:/[,:]\s*/,end:/[<\(,){\s]|$/, excludeBegin:!0,returnEnd:!0},s,o]},r,{className:"meta",begin:"^#!/usr/bin/env", end:"$",illegal:"\n"},l]}},grmr_less:e=>{ -const n=ie(e),t=de,a="[\\w-]+",i="("+a+"|@\\{"+a+"\\})",r=[],s=[],o=e=>({ +const n=te(e),t=le,a="[\\w-]+",i="("+a+"|@\\{"+a+"\\})",r=[],s=[],o=e=>({ className:"string",begin:"~?"+e+".*?"+e}),l=(e,n,t)=>({className:e,begin:n, relevance:t}),c={$pattern:/[a-z-]+/,keyword:"and or not only", -attribute:se.join(" ")},d={begin:"\\(",end:"\\)",contains:s,keywords:c, +attribute:ie.join(" ")},d={begin:"\\(",end:"\\)",contains:s,keywords:c, relevance:0} ;s.push(e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,o("'"),o('"'),n.CSS_NUMBER_MODE,{ begin:"(url|data-uri)\\(",starts:{className:"string",end:"[\\)\\n]", @@ -687,7 +697,7 @@ className:"attribute",begin:a+"\\s*:",end:":",returnBegin:!0,excludeEnd:!0 begin:/\{/,end:/\}/,contains:r}),u={beginKeywords:"when",endsWithParent:!0, contains:[{beginKeywords:"and not"}].concat(s)},b={begin:i+"\\s*:", returnBegin:!0,end:/[;}]/,relevance:0,contains:[{begin:/-(webkit|moz|ms|o)-/ -},n.CSS_VARIABLE,{className:"attribute",begin:"\\b("+ce.join("|")+")\\b", +},n.CSS_VARIABLE,{className:"attribute",begin:"\\b("+oe.join("|")+")\\b", end:/(?=:)/,starts:{endsWithParent:!0,illegal:"[<=$]",relevance:0,contains:s}}] },m={className:"keyword", begin:"@(import|media|charset|font-face|(-[a-z]+-)?keyframes|supports|document|namespace|page|viewport|host)\\b", @@ -697,18 +707,18 @@ className:"variable",variants:[{begin:"@"+a+"\\s*:",relevance:15},{begin:"@"+a begin:"[\\.#:&\\[>]",end:"[;{}]"},{begin:i,end:/\{/}],returnBegin:!0, returnEnd:!0,illegal:"[<='$\"]",relevance:0, contains:[e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,u,l("keyword","all\\b"),l("variable","@\\{"+a+"\\}"),{ -begin:"\\b("+re.join("|")+")\\b",className:"selector-tag" +begin:"\\b("+ae.join("|")+")\\b",className:"selector-tag" },n.CSS_NUMBER_MODE,l("selector-tag",i,0),l("selector-id","#"+i),l("selector-class","\\."+i,0),l("selector-tag","&",0),n.ATTRIBUTE_SELECTOR_MODE,{ -className:"selector-pseudo",begin:":("+oe.join("|")+")"},{ -className:"selector-pseudo",begin:":(:)?("+le.join("|")+")"},{begin:/\(/, +className:"selector-pseudo",begin:":("+re.join("|")+")"},{ +className:"selector-pseudo",begin:":(:)?("+se.join("|")+")"},{begin:/\(/, end:/\)/,relevance:0,contains:g},{begin:"!important"},n.FUNCTION_DISPATCH]},h={ begin:a+":(:)?"+`(${t.join("|")})`,returnBegin:!0,contains:[_]} ;return r.push(e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,m,p,h,b,_,u,n.FUNCTION_DISPATCH), {name:"Less",case_insensitive:!0,illegal:"[=>'/<($\"]",contains:r}}, grmr_lua:e=>{const n="\\[=*\\[",t="\\]=*\\]",a={begin:n,end:t,contains:["self"] },i=[e.COMMENT("--(?!"+n+")","$"),e.COMMENT("--"+n,t,{contains:[a],relevance:10 -})];return{name:"Lua",keywords:{$pattern:e.UNDERSCORE_IDENT_RE, -literal:"true false nil", +})];return{name:"Lua",aliases:["pluto"],keywords:{ +$pattern:e.UNDERSCORE_IDENT_RE,literal:"true false nil", keyword:"and break do else elseif end for goto if in local not or repeat return then until while", built_in:"_G _ENV _VERSION __index __newindex __mode __call __metatable __tostring __len __gc __add __sub __mul __div __mod __pow __concat __unm __eq __lt __le assert collectgarbage dofile error getfenv getmetatable ipairs load loadfile loadstring module next pairs pcall print rawequal rawget rawset require select setfenv setmetatable tonumber tostring type unpack xpcall arg self coroutine resume yield status wrap create running debug getupvalue debug sethook getmetatable gethook setmetatable setlocal traceback setfenv getinfo setupvalue getlocal getregistry getfenv io lines write close flush open output type read stderr stdin input stdout popen tmpfile math log max acos huge ldexp pi cos tanh pow deg tan cosh sinh random randomseed frexp ceil floor rad abs sqrt modf asin min mod fmod log10 atan2 exp sin atan os exit setlocale date getenv difftime remove time clock tmpname rename execute package preload loadlib loaded loaders cpath config path seeall string sub upper len gfind rep find match char dump gmatch reverse byte format gsub lower table setn insert getn foreachi maxn foreach concat sort remove" },contains:i.concat([{className:"function",beginKeywords:"function",end:"\\)", @@ -722,7 +732,7 @@ contains:[e.BACKSLASH_ESCAPE]},{begin:/\$[@%{ -const n=/[a-zA-Z@][a-zA-Z0-9_]*/,t={$pattern:n, +className:"link",begin:/:\s*/,end:/$/,excludeBegin:!0}]},{scope:"literal", +match:/&([a-zA-Z0-9]+|#[0-9]{1,7}|#[Xx][0-9a-fA-F]{1,6});/}]}}, +grmr_objectivec:e=>{const n=/[a-zA-Z@][a-zA-Z0-9_]*/,t={$pattern:n, keyword:["@interface","@class","@protocol","@implementation"]};return{ name:"Objective-C",aliases:["mm","objc","obj-c","obj-c++","objective-c++"], keywords:{"variable.language":["this","super"],$pattern:n, @@ -778,94 +789,99 @@ begin:"("+t.keyword.join("|")+")\\b",end:/(\{|$)/,excludeEnd:!0,keywords:t, contains:[e.UNDERSCORE_TITLE_MODE]},{begin:"\\."+e.UNDERSCORE_IDENT_RE, relevance:0}]}},grmr_perl:e=>{const n=e.regex,t=/[dualxmsipngr]{0,12}/,a={ $pattern:/[\w.]+/, -keyword:"abs accept alarm and atan2 bind binmode bless break caller chdir chmod chomp chop chown chr chroot close closedir connect continue cos crypt dbmclose dbmopen defined delete die do dump each else elsif endgrent endhostent endnetent endprotoent endpwent endservent eof eval exec exists exit exp fcntl fileno flock for foreach fork format formline getc getgrent getgrgid getgrnam gethostbyaddr gethostbyname gethostent getlogin getnetbyaddr getnetbyname getnetent getpeername getpgrp getpriority getprotobyname getprotobynumber getprotoent getpwent getpwnam getpwuid getservbyname getservbyport getservent getsockname getsockopt given glob gmtime goto grep gt hex if index int ioctl join keys kill last lc lcfirst length link listen local localtime log lstat lt ma map mkdir msgctl msgget msgrcv msgsnd my ne next no not oct open opendir or ord our pack package pipe pop pos print printf prototype push q|0 qq quotemeta qw qx rand read readdir readline readlink readpipe recv redo ref rename require reset return reverse rewinddir rindex rmdir say scalar seek seekdir select semctl semget semop send setgrent sethostent setnetent setpgrp setpriority setprotoent setpwent setservent setsockopt shift shmctl shmget shmread shmwrite shutdown sin sleep socket socketpair sort splice split sprintf sqrt srand stat state study sub substr symlink syscall sysopen sysread sysseek system syswrite tell telldir tie tied time times tr truncate uc ucfirst umask undef unless unlink unpack unshift untie until use utime values vec wait waitpid wantarray warn when while write x|0 xor y|0" +keyword:"abs accept alarm and atan2 bind binmode bless break caller chdir chmod chomp chop chown chr chroot class close closedir connect continue cos crypt dbmclose dbmopen defined delete die do dump each else elsif endgrent endhostent endnetent endprotoent endpwent endservent eof eval exec exists exit exp fcntl field fileno flock for foreach fork format formline getc getgrent getgrgid getgrnam gethostbyaddr gethostbyname gethostent getlogin getnetbyaddr getnetbyname getnetent getpeername getpgrp getpriority getprotobyname getprotobynumber getprotoent getpwent getpwnam getpwuid getservbyname getservbyport getservent getsockname getsockopt given glob gmtime goto grep gt hex if index int ioctl join keys kill last lc lcfirst length link listen local localtime log lstat lt ma map method mkdir msgctl msgget msgrcv msgsnd my ne next no not oct open opendir or ord our pack package pipe pop pos print printf prototype push q|0 qq quotemeta qw qx rand read readdir readline readlink readpipe recv redo ref rename require reset return reverse rewinddir rindex rmdir say scalar seek seekdir select semctl semget semop send setgrent sethostent setnetent setpgrp setpriority setprotoent setpwent setservent setsockopt shift shmctl shmget shmread shmwrite shutdown sin sleep socket socketpair sort splice split sprintf sqrt srand stat state study sub substr symlink syscall sysopen sysread sysseek system syswrite tell telldir tie tied time times tr truncate uc ucfirst umask undef unless unlink unpack unshift untie until use utime values vec wait waitpid wantarray warn when while write x|0 xor y|0" },i={className:"subst",begin:"[$@]\\{",end:"\\}",keywords:a},r={begin:/->\{/, -end:/\}/},s={variants:[{begin:/\$\d/},{ -begin:n.concat(/[$%@](\^\w\b|#\w+(::\w+)*|\{\w+\}|\w+(::\w*)*)/,"(?![A-Za-z])(?![@$%])") -},{begin:/[$%@][^\s\w{]/,relevance:0}] -},o=[e.BACKSLASH_ESCAPE,i,s],l=[/!/,/\//,/\|/,/\?/,/'/,/"/,/#/],c=(e,a,i="\\1")=>{ +end:/\}/},s={scope:"attr",match:/\s+:\s*\w+(\s*\(.*?\))?/},o={scope:"variable", +variants:[{begin:/\$\d/},{ +begin:n.concat(/[$%@](?!")(\^\w\b|#\w+(::\w+)*|\{\w+\}|\w+(::\w*)*)/,"(?![A-Za-z])(?![@$%])") +},{begin:/[$%@](?!")[^\s\w{=]|\$=/,relevance:0}],contains:[s]},l={ +className:"number",variants:[{match:/0?\.[0-9][0-9_]+\b/},{ +match:/\bv?(0|[1-9][0-9_]*(\.[0-9_]+)?|[1-9][0-9_]*)\b/},{ +match:/\b0[0-7][0-7_]*\b/},{match:/\b0x[0-9a-fA-F][0-9a-fA-F_]*\b/},{ +match:/\b0b[0-1][0-1_]*\b/}],relevance:0 +},c=[e.BACKSLASH_ESCAPE,i,o],d=[/!/,/\//,/\|/,/\?/,/'/,/"/,/#/],g=(e,a,i="\\1")=>{ const r="\\1"===i?i:n.concat(i,a) ;return n.concat(n.concat("(?:",e,")"),a,/(?:\\.|[^\\\/])*?/,r,/(?:\\.|[^\\\/])*?/,i,t) -},d=(e,a,i)=>n.concat(n.concat("(?:",e,")"),a,/(?:\\.|[^\\\/])*?/,i,t),g=[s,e.HASH_COMMENT_MODE,e.COMMENT(/^=\w/,/=cut/,{ -endsWithParent:!0}),r,{className:"string",contains:o,variants:[{ +},u=(e,a,i)=>n.concat(n.concat("(?:",e,")"),a,/(?:\\.|[^\\\/])*?/,i,t),b=[o,e.HASH_COMMENT_MODE,e.COMMENT(/^=\w/,/=cut/,{ +endsWithParent:!0}),r,{className:"string",contains:c,variants:[{ begin:"q[qwxr]?\\s*\\(",end:"\\)",relevance:5},{begin:"q[qwxr]?\\s*\\[", end:"\\]",relevance:5},{begin:"q[qwxr]?\\s*\\{",end:"\\}",relevance:5},{ begin:"q[qwxr]?\\s*\\|",end:"\\|",relevance:5},{begin:"q[qwxr]?\\s*<",end:">", relevance:5},{begin:"qw\\s+q",end:"q",relevance:5},{begin:"'",end:"'", contains:[e.BACKSLASH_ESCAPE]},{begin:'"',end:'"'},{begin:"`",end:"`", contains:[e.BACKSLASH_ESCAPE]},{begin:/\{\w+\}/,relevance:0},{ -begin:"-?\\w+\\s*=>",relevance:0}]},{className:"number", -begin:"(\\b0[0-7_]+)|(\\b0x[0-9a-fA-F_]+)|(\\b[1-9][0-9_]*(\\.[0-9_]+)?)|[0_]\\b", -relevance:0},{ +begin:"-?\\w+\\s*=>",relevance:0}]},l,{ begin:"(\\/\\/|"+e.RE_STARTERS_RE+"|\\b(split|return|print|reverse|grep)\\b)\\s*", keywords:"split return print reverse grep",relevance:0, contains:[e.HASH_COMMENT_MODE,{className:"regexp",variants:[{ -begin:c("s|tr|y",n.either(...l,{capture:!0}))},{begin:c("s|tr|y","\\(","\\)")},{ -begin:c("s|tr|y","\\[","\\]")},{begin:c("s|tr|y","\\{","\\}")}],relevance:2},{ +begin:g("s|tr|y",n.either(...d,{capture:!0}))},{begin:g("s|tr|y","\\(","\\)")},{ +begin:g("s|tr|y","\\[","\\]")},{begin:g("s|tr|y","\\{","\\}")}],relevance:2},{ className:"regexp",variants:[{begin:/(m|qr)\/\//,relevance:0},{ -begin:d("(?:m|qr)?",/\//,/\//)},{begin:d("m|qr",n.either(...l,{capture:!0 -}),/\1/)},{begin:d("m|qr",/\(/,/\)/)},{begin:d("m|qr",/\[/,/\]/)},{ -begin:d("m|qr",/\{/,/\}/)}]}]},{className:"function",beginKeywords:"sub", -end:"(\\s*\\(.*?\\))?[;{]",excludeEnd:!0,relevance:5,contains:[e.TITLE_MODE]},{ -begin:"-\\w\\b",relevance:0},{begin:"^__DATA__$",end:"^__END__$", -subLanguage:"mojolicious",contains:[{begin:"^@@.*",end:"$",className:"comment"}] -}];return i.contains=g,r.contains=g,{name:"Perl",aliases:["pl","pm"],keywords:a, -contains:g}},grmr_php:e=>{ -const n=e.regex,t=/(?![A-Za-z0-9])(?![$])/,a=n.concat(/[a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff]*/,t),i=n.concat(/(\\?[A-Z][a-z0-9_\x7f-\xff]+|\\?[A-Z]+(?=[A-Z][a-z0-9_\x7f-\xff])){1,}/,t),r={ -scope:"variable",match:"\\$+"+a},s={scope:"subst",variants:[{begin:/\$\w+/},{ -begin:/\{\$/,end:/\}/}]},o=e.inherit(e.APOS_STRING_MODE,{illegal:null -}),l="[ \t\n]",c={scope:"string",variants:[e.inherit(e.QUOTE_STRING_MODE,{ -illegal:null,contains:e.QUOTE_STRING_MODE.contains.concat(s)}),o,{ +begin:u("(?:m|qr)?",/\//,/\//)},{begin:u("m|qr",n.either(...d,{capture:!0 +}),/\1/)},{begin:u("m|qr",/\(/,/\)/)},{begin:u("m|qr",/\[/,/\]/)},{ +begin:u("m|qr",/\{/,/\}/)}]}]},{className:"function",beginKeywords:"sub method", +end:"(\\s*\\(.*?\\))?[;{]",excludeEnd:!0,relevance:5,contains:[e.TITLE_MODE,s] +},{className:"class",beginKeywords:"class",end:"[;{]",excludeEnd:!0,relevance:5, +contains:[e.TITLE_MODE,s,l]},{begin:"-\\w\\b",relevance:0},{begin:"^__DATA__$", +end:"^__END__$",subLanguage:"mojolicious",contains:[{begin:"^@@.*",end:"$", +className:"comment"}]}];return i.contains=b,r.contains=b,{name:"Perl", +aliases:["pl","pm"],keywords:a,contains:b}},grmr_php:e=>{ +const n=e.regex,t=/(?![A-Za-z0-9])(?![$])/,a=n.concat(/[a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff]*/,t),i=n.concat(/(\\?[A-Z][a-z0-9_\x7f-\xff]+|\\?[A-Z]+(?=[A-Z][a-z0-9_\x7f-\xff])){1,}/,t),r=n.concat(/[A-Z]+/,t),s={ +scope:"variable",match:"\\$+"+a},o={scope:"subst",variants:[{begin:/\$\w+/},{ +begin:/\{\$/,end:/\}/}]},l=e.inherit(e.APOS_STRING_MODE,{illegal:null +}),c="[ \t\n]",d={scope:"string",variants:[e.inherit(e.QUOTE_STRING_MODE,{ +illegal:null,contains:e.QUOTE_STRING_MODE.contains.concat(o)}),l,{ begin:/<<<[ \t]*(?:(\w+)|"(\w+)")\n/,end:/[ \t]*(\w+)\b/, -contains:e.QUOTE_STRING_MODE.contains.concat(s),"on:begin":(e,n)=>{ +contains:e.QUOTE_STRING_MODE.contains.concat(o),"on:begin":(e,n)=>{ n.data._beginMatch=e[1]||e[2]},"on:end":(e,n)=>{ n.data._beginMatch!==e[1]&&n.ignoreMatch()}},e.END_SAME_AS_BEGIN({ -begin:/<<<[ \t]*'(\w+)'\n/,end:/[ \t]*(\w+)\b/})]},d={scope:"number",variants:[{ +begin:/<<<[ \t]*'(\w+)'\n/,end:/[ \t]*(\w+)\b/})]},g={scope:"number",variants:[{ begin:"\\b0[bB][01]+(?:_[01]+)*\\b"},{begin:"\\b0[oO][0-7]+(?:_[0-7]+)*\\b"},{ begin:"\\b0[xX][\\da-fA-F]+(?:_[\\da-fA-F]+)*\\b"},{ begin:"(?:\\b\\d+(?:_\\d+)*(\\.(?:\\d+(?:_\\d+)*))?|\\B\\.\\d+)(?:[eE][+-]?\\d+)?" }],relevance:0 -},g=["false","null","true"],u=["__CLASS__","__DIR__","__FILE__","__FUNCTION__","__COMPILER_HALT_OFFSET__","__LINE__","__METHOD__","__NAMESPACE__","__TRAIT__","die","echo","exit","include","include_once","print","require","require_once","array","abstract","and","as","binary","bool","boolean","break","callable","case","catch","class","clone","const","continue","declare","default","do","double","else","elseif","empty","enddeclare","endfor","endforeach","endif","endswitch","endwhile","enum","eval","extends","final","finally","float","for","foreach","from","global","goto","if","implements","instanceof","insteadof","int","integer","interface","isset","iterable","list","match|0","mixed","new","never","object","or","private","protected","public","readonly","real","return","string","switch","throw","trait","try","unset","use","var","void","while","xor","yield"],b=["Error|0","AppendIterator","ArgumentCountError","ArithmeticError","ArrayIterator","ArrayObject","AssertionError","BadFunctionCallException","BadMethodCallException","CachingIterator","CallbackFilterIterator","CompileError","Countable","DirectoryIterator","DivisionByZeroError","DomainException","EmptyIterator","ErrorException","Exception","FilesystemIterator","FilterIterator","GlobIterator","InfiniteIterator","InvalidArgumentException","IteratorIterator","LengthException","LimitIterator","LogicException","MultipleIterator","NoRewindIterator","OutOfBoundsException","OutOfRangeException","OuterIterator","OverflowException","ParentIterator","ParseError","RangeException","RecursiveArrayIterator","RecursiveCachingIterator","RecursiveCallbackFilterIterator","RecursiveDirectoryIterator","RecursiveFilterIterator","RecursiveIterator","RecursiveIteratorIterator","RecursiveRegexIterator","RecursiveTreeIterator","RegexIterator","RuntimeException","SeekableIterator","SplDoublyLinkedList","SplFileInfo","SplFileObject","SplFixedArray","SplHeap","SplMaxHeap","SplMinHeap","SplObjectStorage","SplObserver","SplPriorityQueue","SplQueue","SplStack","SplSubject","SplTempFileObject","TypeError","UnderflowException","UnexpectedValueException","UnhandledMatchError","ArrayAccess","BackedEnum","Closure","Fiber","Generator","Iterator","IteratorAggregate","Serializable","Stringable","Throwable","Traversable","UnitEnum","WeakReference","WeakMap","Directory","__PHP_Incomplete_Class","parent","php_user_filter","self","static","stdClass"],m={ -keyword:u,literal:(e=>{const n=[];return e.forEach((e=>{ +},u=["false","null","true"],b=["__CLASS__","__DIR__","__FILE__","__FUNCTION__","__COMPILER_HALT_OFFSET__","__LINE__","__METHOD__","__NAMESPACE__","__TRAIT__","die","echo","exit","include","include_once","print","require","require_once","array","abstract","and","as","binary","bool","boolean","break","callable","case","catch","class","clone","const","continue","declare","default","do","double","else","elseif","empty","enddeclare","endfor","endforeach","endif","endswitch","endwhile","enum","eval","extends","final","finally","float","for","foreach","from","global","goto","if","implements","instanceof","insteadof","int","integer","interface","isset","iterable","list","match|0","mixed","new","never","object","or","private","protected","public","readonly","real","return","string","switch","throw","trait","try","unset","use","var","void","while","xor","yield"],m=["Error|0","AppendIterator","ArgumentCountError","ArithmeticError","ArrayIterator","ArrayObject","AssertionError","BadFunctionCallException","BadMethodCallException","CachingIterator","CallbackFilterIterator","CompileError","Countable","DirectoryIterator","DivisionByZeroError","DomainException","EmptyIterator","ErrorException","Exception","FilesystemIterator","FilterIterator","GlobIterator","InfiniteIterator","InvalidArgumentException","IteratorIterator","LengthException","LimitIterator","LogicException","MultipleIterator","NoRewindIterator","OutOfBoundsException","OutOfRangeException","OuterIterator","OverflowException","ParentIterator","ParseError","RangeException","RecursiveArrayIterator","RecursiveCachingIterator","RecursiveCallbackFilterIterator","RecursiveDirectoryIterator","RecursiveFilterIterator","RecursiveIterator","RecursiveIteratorIterator","RecursiveRegexIterator","RecursiveTreeIterator","RegexIterator","RuntimeException","SeekableIterator","SplDoublyLinkedList","SplFileInfo","SplFileObject","SplFixedArray","SplHeap","SplMaxHeap","SplMinHeap","SplObjectStorage","SplObserver","SplPriorityQueue","SplQueue","SplStack","SplSubject","SplTempFileObject","TypeError","UnderflowException","UnexpectedValueException","UnhandledMatchError","ArrayAccess","BackedEnum","Closure","Fiber","Generator","Iterator","IteratorAggregate","Serializable","Stringable","Throwable","Traversable","UnitEnum","WeakReference","WeakMap","Directory","__PHP_Incomplete_Class","parent","php_user_filter","self","static","stdClass"],p={ +keyword:b,literal:(e=>{const n=[];return e.forEach((e=>{ n.push(e),e.toLowerCase()===e?n.push(e.toUpperCase()):n.push(e.toLowerCase()) -})),n})(g),built_in:b},p=e=>e.map((e=>e.replace(/\|\d+$/,""))),_={variants:[{ -match:[/new/,n.concat(l,"+"),n.concat("(?!",p(b).join("\\b|"),"\\b)"),i],scope:{ -1:"keyword",4:"title.class"}}]},h=n.concat(a,"\\b(?!\\()"),f={variants:[{ -match:[n.concat(/::/,n.lookahead(/(?!class\b)/)),h],scope:{2:"variable.constant" +})),n})(u),built_in:m},_=e=>e.map((e=>e.replace(/\|\d+$/,""))),h={variants:[{ +match:[/new/,n.concat(c,"+"),n.concat("(?!",_(m).join("\\b|"),"\\b)"),i],scope:{ +1:"keyword",4:"title.class"}}]},f=n.concat(a,"\\b(?!\\()"),E={variants:[{ +match:[n.concat(/::/,n.lookahead(/(?!class\b)/)),f],scope:{2:"variable.constant" }},{match:[/::/,/class/],scope:{2:"variable.language"}},{ -match:[i,n.concat(/::/,n.lookahead(/(?!class\b)/)),h],scope:{1:"title.class", +match:[i,n.concat(/::/,n.lookahead(/(?!class\b)/)),f],scope:{1:"title.class", 3:"variable.constant"}},{match:[i,n.concat("::",n.lookahead(/(?!class\b)/))], scope:{1:"title.class"}},{match:[i,/::/,/class/],scope:{1:"title.class", -3:"variable.language"}}]},E={scope:"attr", -match:n.concat(a,n.lookahead(":"),n.lookahead(/(?!::)/))},y={relevance:0, -begin:/\(/,end:/\)/,keywords:m,contains:[E,r,f,e.C_BLOCK_COMMENT_MODE,c,d,_] -},N={relevance:0, -match:[/\b/,n.concat("(?!fn\\b|function\\b|",p(u).join("\\b|"),"|",p(b).join("\\b|"),"\\b)"),a,n.concat(l,"*"),n.lookahead(/(?=\()/)], -scope:{3:"title.function.invoke"},contains:[y]};y.contains.push(N) -;const w=[E,f,e.C_BLOCK_COMMENT_MODE,c,d,_];return{case_insensitive:!1, -keywords:m,contains:[{begin:n.concat(/#\[\s*/,i),beginScope:"meta",end:/]/, -endScope:"meta",keywords:{literal:g,keyword:["new","array"]},contains:[{ -begin:/\[/,end:/]/,keywords:{literal:g,keyword:["new","array"]}, -contains:["self",...w]},...w,{scope:"meta",match:i}] -},e.HASH_COMMENT_MODE,e.COMMENT("//","$"),e.COMMENT("/\\*","\\*/",{contains:[{ -scope:"doctag",match:"@[A-Za-z]+"}]}),{match:/__halt_compiler\(\);/, +3:"variable.language"}}]},y={scope:"attr", +match:n.concat(a,n.lookahead(":"),n.lookahead(/(?!::)/))},w={relevance:0, +begin:/\(/,end:/\)/,keywords:p,contains:[y,s,E,e.C_BLOCK_COMMENT_MODE,d,g,h] +},v={relevance:0, +match:[/\b/,n.concat("(?!fn\\b|function\\b|",_(b).join("\\b|"),"|",_(m).join("\\b|"),"\\b)"),a,n.concat(c,"*"),n.lookahead(/(?=\()/)], +scope:{3:"title.function.invoke"},contains:[w]};w.contains.push(v) +;const N=[y,E,e.C_BLOCK_COMMENT_MODE,d,g,h],k={ +begin:n.concat(/#\[\s*\\?/,n.either(i,r)),beginScope:"meta",end:/]/, +endScope:"meta",keywords:{literal:u,keyword:["new","array"]},contains:[{ +begin:/\[/,end:/]/,keywords:{literal:u,keyword:["new","array"]}, +contains:["self",...N]},...N,{scope:"meta",variants:[{match:i},{match:r}]}]} +;return{case_insensitive:!1,keywords:p, +contains:[k,e.HASH_COMMENT_MODE,e.COMMENT("//","$"),e.COMMENT("/\\*","\\*/",{ +contains:[{scope:"doctag",match:"@[A-Za-z]+"}]}),{match:/__halt_compiler\(\);/, keywords:"__halt_compiler",starts:{scope:"comment",end:e.MATCH_NOTHING_RE, contains:[{match:/\?>/,scope:"meta",endsParent:!0}]}},{scope:"meta",variants:[{ begin:/<\?php/,relevance:10},{begin:/<\?=/},{begin:/<\?/,relevance:.1},{ -begin:/\?>/}]},{scope:"variable.language",match:/\$this\b/},r,N,f,{ -match:[/const/,/\s/,a],scope:{1:"keyword",3:"variable.constant"}},_,{ +begin:/\?>/}]},{scope:"variable.language",match:/\$this\b/},s,v,E,{ +match:[/const/,/\s/,a],scope:{1:"keyword",3:"variable.constant"}},h,{ scope:"function",relevance:0,beginKeywords:"fn function",end:/[;{]/, excludeEnd:!0,illegal:"[$%\\[]",contains:[{beginKeywords:"use" },e.UNDERSCORE_TITLE_MODE,{begin:"=>",endsParent:!0},{scope:"params", -begin:"\\(",end:"\\)",excludeBegin:!0,excludeEnd:!0,keywords:m, -contains:["self",r,f,e.C_BLOCK_COMMENT_MODE,c,d]}]},{scope:"class",variants:[{ +begin:"\\(",end:"\\)",excludeBegin:!0,excludeEnd:!0,keywords:p, +contains:["self",k,s,E,e.C_BLOCK_COMMENT_MODE,d,g]}]},{scope:"class",variants:[{ beginKeywords:"enum",illegal:/[($"]/},{beginKeywords:"class interface trait", illegal:/[:($"]/}],relevance:0,end:/\{/,excludeEnd:!0,contains:[{ beginKeywords:"extends implements"},e.UNDERSCORE_TITLE_MODE]},{ beginKeywords:"namespace",relevance:0,end:";",illegal:/[.']/, contains:[e.inherit(e.UNDERSCORE_TITLE_MODE,{scope:"title.class"})]},{ beginKeywords:"use",relevance:0,end:";",contains:[{ -match:/\b(as|const|function)\b/,scope:"keyword"},e.UNDERSCORE_TITLE_MODE]},c,d]} +match:/\b(as|const|function)\b/,scope:"keyword"},e.UNDERSCORE_TITLE_MODE]},d,g]} },grmr_php_template:e=>({name:"PHP template",subLanguage:"xml",contains:[{ begin:/<\?(php|=)?/,end:/\?>/,subLanguage:"php",contains:[{begin:"/\\*", end:"\\*/",skip:!0},{begin:'b"',end:'"',skip:!0},{begin:"b'",end:"'",skip:!0 @@ -905,9 +921,10 @@ className:"params",variants:[{className:"",begin:/\(\s*\)/,skip:!0},{begin:/\(/, end:/\)/,excludeBegin:!0,excludeEnd:!0,keywords:i, contains:["self",r,u,l,e.HASH_COMMENT_MODE]}]};return s.contains=[l,u,r],{ name:"Python",aliases:["py","gyp","ipython"],unicodeRegex:!0,keywords:i, -illegal:/(<\/|\?)|=>/,contains:[r,u,{begin:/\bself\b/},{beginKeywords:"if", -relevance:0},l,b,e.HASH_COMMENT_MODE,{match:[/\bdef/,/\s+/,t],scope:{ -1:"keyword",3:"title.function"},contains:[m]},{variants:[{ +illegal:/(<\/|\?)|=>/,contains:[r,u,{scope:"variable.language",match:/\bself\b/ +},{beginKeywords:"if",relevance:0},{match:/\bor\b/,scope:"keyword" +},l,b,e.HASH_COMMENT_MODE,{match:[/\bdef/,/\s+/,t],scope:{1:"keyword", +3:"title.function"},contains:[m]},{variants:[{ match:[/\bclass/,/\s+/,t,/\s*/,/\(\s*/,t,/\s*\)/]},{match:[/\bclass/,/\s+/,t]}], scope:{1:"keyword",3:"title.class",6:"title.class.inherited"}},{ className:"meta",begin:/^[\t ]*@/,end:/(?=#)|$/,contains:[u,m,l]}]}}, @@ -977,7 +994,7 @@ begin:e.IDENT_RE+"::"},{className:"symbol", begin:e.UNDERSCORE_IDENT_RE+"(!|\\?)?:",relevance:0},{className:"symbol", begin:":(?!\\s)",contains:[d,{begin:t}],relevance:0},u,{className:"variable", begin:"(\\$\\W)|((\\$|@@?)(\\w+))(?=[^@$?])(?![A-Za-z])(?![@$?'])"},{ -className:"params",begin:/\|/,end:/\|/,excludeBegin:!0,excludeEnd:!0, +className:"params",begin:/\|(?!=)/,end:/\|/,excludeBegin:!0,excludeEnd:!0, relevance:0,keywords:r},{begin:"("+e.RE_STARTERS_RE+"|unless)\\s*", keywords:"unless",contains:[{className:"regexp",contains:[e.BACKSLASH_ESCAPE,c], illegal:/\n/,variants:[{begin:"/",end:"/[a-z]*"},{begin:/%r\{/,end:/\}[a-z]*/},{ @@ -989,140 +1006,151 @@ begin:"^([>?]>|[\\w#]+\\(\\w+\\):\\d+:\\d+[>*]|(\\w+-)?\\d+\\.\\d+\\.\\d+(p\\d+) starts:{end:"$",keywords:r,contains:m}}];return l.unshift(o),{name:"Ruby", aliases:["rb","gemspec","podspec","thor","irb"],keywords:r,illegal:/\/\*/, contains:[e.SHEBANG({binary:"ruby"})].concat(p).concat(l).concat(m)}}, -grmr_rust:e=>{const n=e.regex,t={className:"title.function.invoke",relevance:0, -begin:n.concat(/\b/,/(?!let|for|while|if|else|match\b)/,e.IDENT_RE,n.lookahead(/\s*\(/)) -},a="([ui](8|16|32|64|128|size)|f(32|64))?",i=["drop ","Copy","Send","Sized","Sync","Drop","Fn","FnMut","FnOnce","ToOwned","Clone","Debug","PartialEq","PartialOrd","Eq","Ord","AsRef","AsMut","Into","From","Default","Iterator","Extend","IntoIterator","DoubleEndedIterator","ExactSizeIterator","SliceConcatExt","ToString","assert!","assert_eq!","bitflags!","bytes!","cfg!","col!","concat!","concat_idents!","debug_assert!","debug_assert_eq!","env!","eprintln!","panic!","file!","format!","format_args!","include_bytes!","include_str!","line!","local_data_key!","module_path!","option_env!","print!","println!","select!","stringify!","try!","unimplemented!","unreachable!","vec!","write!","writeln!","macro_rules!","assert_ne!","debug_assert_ne!"],r=["i8","i16","i32","i64","i128","isize","u8","u16","u32","u64","u128","usize","f32","f64","str","char","bool","Box","Option","Result","String","Vec"] -;return{name:"Rust",aliases:["rs"],keywords:{$pattern:e.IDENT_RE+"!?",type:r, -keyword:["abstract","as","async","await","become","box","break","const","continue","crate","do","dyn","else","enum","extern","false","final","fn","for","if","impl","in","let","loop","macro","match","mod","move","mut","override","priv","pub","ref","return","self","Self","static","struct","super","trait","true","try","type","typeof","unsafe","unsized","use","virtual","where","while","yield"], -literal:["true","false","Some","None","Ok","Err"],built_in:i},illegal:"{ +const n=e.regex,t=/(r#)?/,a=n.concat(t,e.UNDERSCORE_IDENT_RE),i=n.concat(t,e.IDENT_RE),r={ +className:"title.function.invoke",relevance:0, +begin:n.concat(/\b/,/(?!let|for|while|if|else|match\b)/,i,n.lookahead(/\s*\(/)) +},s="([ui](8|16|32|64|128|size)|f(32|64))?",o=["drop ","Copy","Send","Sized","Sync","Drop","Fn","FnMut","FnOnce","ToOwned","Clone","Debug","PartialEq","PartialOrd","Eq","Ord","AsRef","AsMut","Into","From","Default","Iterator","Extend","IntoIterator","DoubleEndedIterator","ExactSizeIterator","SliceConcatExt","ToString","assert!","assert_eq!","bitflags!","bytes!","cfg!","col!","concat!","concat_idents!","debug_assert!","debug_assert_eq!","env!","eprintln!","panic!","file!","format!","format_args!","include_bytes!","include_str!","line!","local_data_key!","module_path!","option_env!","print!","println!","select!","stringify!","try!","unimplemented!","unreachable!","vec!","write!","writeln!","macro_rules!","assert_ne!","debug_assert_ne!"],l=["i8","i16","i32","i64","i128","isize","u8","u16","u32","u64","u128","usize","f32","f64","str","char","bool","Box","Option","Result","String","Vec"] +;return{name:"Rust",aliases:["rs"],keywords:{$pattern:e.IDENT_RE+"!?",type:l, +keyword:["abstract","as","async","await","become","box","break","const","continue","crate","do","dyn","else","enum","extern","false","final","fn","for","if","impl","in","let","loop","macro","match","mod","move","mut","override","priv","pub","ref","return","self","Self","static","struct","super","trait","true","try","type","typeof","union","unsafe","unsized","use","virtual","where","while","yield"], +literal:["true","false","Some","None","Ok","Err"],built_in:o},illegal:""},t]}}, -grmr_scss:e=>{const n=ie(e),t=le,a=oe,i="@[a-z-]+",r={className:"variable", +keyword:"Self",built_in:o,type:l}},{className:"punctuation",begin:"->"},r]}}, +grmr_scss:e=>{const n=te(e),t=se,a=re,i="@[a-z-]+",r={className:"variable", begin:"(\\$[a-zA-Z-][a-zA-Z0-9_-]*)\\b",relevance:0};return{name:"SCSS", case_insensitive:!0,illegal:"[=/|']", contains:[e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,n.CSS_NUMBER_MODE,{ className:"selector-id",begin:"#[A-Za-z0-9_-]+",relevance:0},{ className:"selector-class",begin:"\\.[A-Za-z0-9_-]+",relevance:0 },n.ATTRIBUTE_SELECTOR_MODE,{className:"selector-tag", -begin:"\\b("+re.join("|")+")\\b",relevance:0},{className:"selector-pseudo", +begin:"\\b("+ae.join("|")+")\\b",relevance:0},{className:"selector-pseudo", begin:":("+a.join("|")+")"},{className:"selector-pseudo", begin:":(:)?("+t.join("|")+")"},r,{begin:/\(/,end:/\)/, contains:[n.CSS_NUMBER_MODE]},n.CSS_VARIABLE,{className:"attribute", -begin:"\\b("+ce.join("|")+")\\b"},{ +begin:"\\b("+oe.join("|")+")\\b"},{ begin:"\\b(whitespace|wait|w-resize|visible|vertical-text|vertical-ideographic|uppercase|upper-roman|upper-alpha|underline|transparent|top|thin|thick|text|text-top|text-bottom|tb-rl|table-header-group|table-footer-group|sw-resize|super|strict|static|square|solid|small-caps|separate|se-resize|scroll|s-resize|rtl|row-resize|ridge|right|repeat|repeat-y|repeat-x|relative|progress|pointer|overline|outside|outset|oblique|nowrap|not-allowed|normal|none|nw-resize|no-repeat|no-drop|newspaper|ne-resize|n-resize|move|middle|medium|ltr|lr-tb|lowercase|lower-roman|lower-alpha|loose|list-item|line|line-through|line-edge|lighter|left|keep-all|justify|italic|inter-word|inter-ideograph|inside|inset|inline|inline-block|inherit|inactive|ideograph-space|ideograph-parenthesis|ideograph-numeric|ideograph-alpha|horizontal|hidden|help|hand|groove|fixed|ellipsis|e-resize|double|dotted|distribute|distribute-space|distribute-letter|distribute-all-lines|disc|disabled|default|decimal|dashed|crosshair|collapse|col-resize|circle|char|center|capitalize|break-word|break-all|bottom|both|bolder|bold|block|bidi-override|below|baseline|auto|always|all-scroll|absolute|table|table-cell)\\b" },{begin:/:/,end:/[;}{]/,relevance:0, contains:[n.BLOCK_COMMENT,r,n.HEXCOLOR,n.CSS_NUMBER_MODE,e.QUOTE_STRING_MODE,e.APOS_STRING_MODE,n.IMPORTANT,n.FUNCTION_DISPATCH] },{begin:"@(page|font-face)",keywords:{$pattern:i,keyword:"@page @font-face"}},{ begin:"@",end:"[{;]",returnBegin:!0,keywords:{$pattern:/[a-z-]+/, -keyword:"and or not only",attribute:se.join(" ")},contains:[{begin:i, +keyword:"and or not only",attribute:ie.join(" ")},contains:[{begin:i, className:"keyword"},{begin:/[a-z-]+(?=:)/,className:"attribute" },r,e.QUOTE_STRING_MODE,e.APOS_STRING_MODE,n.HEXCOLOR,n.CSS_NUMBER_MODE] },n.FUNCTION_DISPATCH]}},grmr_shell:e=>({name:"Shell Session", aliases:["console","shellsession"],contains:[{className:"meta.prompt", begin:/^\s{0,3}[/~\w\d[\]()@-]*[>%$#][ ]?/,starts:{end:/[^\\](?=\s*$)/, subLanguage:"bash"}}]}),grmr_sql:e=>{ -const n=e.regex,t=e.COMMENT("--","$"),a=["true","false","unknown"],i=["bigint","binary","blob","boolean","char","character","clob","date","dec","decfloat","decimal","float","int","integer","interval","nchar","nclob","national","numeric","real","row","smallint","time","timestamp","varchar","varying","varbinary"],r=["abs","acos","array_agg","asin","atan","avg","cast","ceil","ceiling","coalesce","corr","cos","cosh","count","covar_pop","covar_samp","cume_dist","dense_rank","deref","element","exp","extract","first_value","floor","json_array","json_arrayagg","json_exists","json_object","json_objectagg","json_query","json_table","json_table_primitive","json_value","lag","last_value","lead","listagg","ln","log","log10","lower","max","min","mod","nth_value","ntile","nullif","percent_rank","percentile_cont","percentile_disc","position","position_regex","power","rank","regr_avgx","regr_avgy","regr_count","regr_intercept","regr_r2","regr_slope","regr_sxx","regr_sxy","regr_syy","row_number","sin","sinh","sqrt","stddev_pop","stddev_samp","substring","substring_regex","sum","tan","tanh","translate","translate_regex","treat","trim","trim_array","unnest","upper","value_of","var_pop","var_samp","width_bucket"],s=["create table","insert into","primary key","foreign key","not null","alter table","add constraint","grouping sets","on overflow","character set","respect nulls","ignore nulls","nulls first","nulls last","depth first","breadth first"],o=r,l=["abs","acos","all","allocate","alter","and","any","are","array","array_agg","array_max_cardinality","as","asensitive","asin","asymmetric","at","atan","atomic","authorization","avg","begin","begin_frame","begin_partition","between","bigint","binary","blob","boolean","both","by","call","called","cardinality","cascaded","case","cast","ceil","ceiling","char","char_length","character","character_length","check","classifier","clob","close","coalesce","collate","collect","column","commit","condition","connect","constraint","contains","convert","copy","corr","corresponding","cos","cosh","count","covar_pop","covar_samp","create","cross","cube","cume_dist","current","current_catalog","current_date","current_default_transform_group","current_path","current_role","current_row","current_schema","current_time","current_timestamp","current_path","current_role","current_transform_group_for_type","current_user","cursor","cycle","date","day","deallocate","dec","decimal","decfloat","declare","default","define","delete","dense_rank","deref","describe","deterministic","disconnect","distinct","double","drop","dynamic","each","element","else","empty","end","end_frame","end_partition","end-exec","equals","escape","every","except","exec","execute","exists","exp","external","extract","false","fetch","filter","first_value","float","floor","for","foreign","frame_row","free","from","full","function","fusion","get","global","grant","group","grouping","groups","having","hold","hour","identity","in","indicator","initial","inner","inout","insensitive","insert","int","integer","intersect","intersection","interval","into","is","join","json_array","json_arrayagg","json_exists","json_object","json_objectagg","json_query","json_table","json_table_primitive","json_value","lag","language","large","last_value","lateral","lead","leading","left","like","like_regex","listagg","ln","local","localtime","localtimestamp","log","log10","lower","match","match_number","match_recognize","matches","max","member","merge","method","min","minute","mod","modifies","module","month","multiset","national","natural","nchar","nclob","new","no","none","normalize","not","nth_value","ntile","null","nullif","numeric","octet_length","occurrences_regex","of","offset","old","omit","on","one","only","open","or","order","out","outer","over","overlaps","overlay","parameter","partition","pattern","per","percent","percent_rank","percentile_cont","percentile_disc","period","portion","position","position_regex","power","precedes","precision","prepare","primary","procedure","ptf","range","rank","reads","real","recursive","ref","references","referencing","regr_avgx","regr_avgy","regr_count","regr_intercept","regr_r2","regr_slope","regr_sxx","regr_sxy","regr_syy","release","result","return","returns","revoke","right","rollback","rollup","row","row_number","rows","running","savepoint","scope","scroll","search","second","seek","select","sensitive","session_user","set","show","similar","sin","sinh","skip","smallint","some","specific","specifictype","sql","sqlexception","sqlstate","sqlwarning","sqrt","start","static","stddev_pop","stddev_samp","submultiset","subset","substring","substring_regex","succeeds","sum","symmetric","system","system_time","system_user","table","tablesample","tan","tanh","then","time","timestamp","timezone_hour","timezone_minute","to","trailing","translate","translate_regex","translation","treat","trigger","trim","trim_array","true","truncate","uescape","union","unique","unknown","unnest","update","upper","user","using","value","values","value_of","var_pop","var_samp","varbinary","varchar","varying","versioning","when","whenever","where","width_bucket","window","with","within","without","year","add","asc","collation","desc","final","first","last","view"].filter((e=>!r.includes(e))),c={ -begin:n.concat(/\b/,n.either(...o),/\s*\(/),relevance:0,keywords:{built_in:o}} -;return{name:"SQL",case_insensitive:!0,illegal:/[{}]|<\//,keywords:{ +const n=e.regex,t=e.COMMENT("--","$"),a=["abs","acos","array_agg","asin","atan","avg","cast","ceil","ceiling","coalesce","corr","cos","cosh","count","covar_pop","covar_samp","cume_dist","dense_rank","deref","element","exp","extract","first_value","floor","json_array","json_arrayagg","json_exists","json_object","json_objectagg","json_query","json_table","json_table_primitive","json_value","lag","last_value","lead","listagg","ln","log","log10","lower","max","min","mod","nth_value","ntile","nullif","percent_rank","percentile_cont","percentile_disc","position","position_regex","power","rank","regr_avgx","regr_avgy","regr_count","regr_intercept","regr_r2","regr_slope","regr_sxx","regr_sxy","regr_syy","row_number","sin","sinh","sqrt","stddev_pop","stddev_samp","substring","substring_regex","sum","tan","tanh","translate","translate_regex","treat","trim","trim_array","unnest","upper","value_of","var_pop","var_samp","width_bucket"],i=a,r=["abs","acos","all","allocate","alter","and","any","are","array","array_agg","array_max_cardinality","as","asensitive","asin","asymmetric","at","atan","atomic","authorization","avg","begin","begin_frame","begin_partition","between","bigint","binary","blob","boolean","both","by","call","called","cardinality","cascaded","case","cast","ceil","ceiling","char","char_length","character","character_length","check","classifier","clob","close","coalesce","collate","collect","column","commit","condition","connect","constraint","contains","convert","copy","corr","corresponding","cos","cosh","count","covar_pop","covar_samp","create","cross","cube","cume_dist","current","current_catalog","current_date","current_default_transform_group","current_path","current_role","current_row","current_schema","current_time","current_timestamp","current_path","current_role","current_transform_group_for_type","current_user","cursor","cycle","date","day","deallocate","dec","decimal","decfloat","declare","default","define","delete","dense_rank","deref","describe","deterministic","disconnect","distinct","double","drop","dynamic","each","element","else","empty","end","end_frame","end_partition","end-exec","equals","escape","every","except","exec","execute","exists","exp","external","extract","false","fetch","filter","first_value","float","floor","for","foreign","frame_row","free","from","full","function","fusion","get","global","grant","group","grouping","groups","having","hold","hour","identity","in","indicator","initial","inner","inout","insensitive","insert","int","integer","intersect","intersection","interval","into","is","join","json_array","json_arrayagg","json_exists","json_object","json_objectagg","json_query","json_table","json_table_primitive","json_value","lag","language","large","last_value","lateral","lead","leading","left","like","like_regex","listagg","ln","local","localtime","localtimestamp","log","log10","lower","match","match_number","match_recognize","matches","max","member","merge","method","min","minute","mod","modifies","module","month","multiset","national","natural","nchar","nclob","new","no","none","normalize","not","nth_value","ntile","null","nullif","numeric","octet_length","occurrences_regex","of","offset","old","omit","on","one","only","open","or","order","out","outer","over","overlaps","overlay","parameter","partition","pattern","per","percent","percent_rank","percentile_cont","percentile_disc","period","portion","position","position_regex","power","precedes","precision","prepare","primary","procedure","ptf","range","rank","reads","real","recursive","ref","references","referencing","regr_avgx","regr_avgy","regr_count","regr_intercept","regr_r2","regr_slope","regr_sxx","regr_sxy","regr_syy","release","result","return","returns","revoke","right","rollback","rollup","row","row_number","rows","running","savepoint","scope","scroll","search","second","seek","select","sensitive","session_user","set","show","similar","sin","sinh","skip","smallint","some","specific","specifictype","sql","sqlexception","sqlstate","sqlwarning","sqrt","start","static","stddev_pop","stddev_samp","submultiset","subset","substring","substring_regex","succeeds","sum","symmetric","system","system_time","system_user","table","tablesample","tan","tanh","then","time","timestamp","timezone_hour","timezone_minute","to","trailing","translate","translate_regex","translation","treat","trigger","trim","trim_array","true","truncate","uescape","union","unique","unknown","unnest","update","upper","user","using","value","values","value_of","var_pop","var_samp","varbinary","varchar","varying","versioning","when","whenever","where","width_bucket","window","with","within","without","year","add","asc","collation","desc","final","first","last","view"].filter((e=>!a.includes(e))),s={ +match:n.concat(/\b/,n.either(...i),/\s*\(/),relevance:0,keywords:{built_in:i}} +;function o(e){ +return n.concat(/\b/,n.either(...e.map((e=>e.replace(/\s+/,"\\s+")))),/\b/)} +const l={scope:"keyword", +match:o(["create table","insert into","primary key","foreign key","not null","alter table","add constraint","grouping sets","on overflow","character set","respect nulls","ignore nulls","nulls first","nulls last","depth first","breadth first"]), +relevance:0};return{name:"SQL",case_insensitive:!0,illegal:/[{}]|<\//,keywords:{ $pattern:/\b[\w\.]+/,keyword:((e,{exceptions:n,when:t}={})=>{const a=t ;return n=n||[],e.map((e=>e.match(/\|\d+$/)||n.includes(e)?e:a(e)?e+"|0":e)) -})(l,{when:e=>e.length<3}),literal:a,type:i, +})(r,{when:e=>e.length<3}),literal:["true","false","unknown"], +type:["bigint","binary","blob","boolean","char","character","clob","date","dec","decfloat","decimal","float","int","integer","interval","nchar","nclob","national","numeric","real","row","smallint","time","timestamp","varchar","varying","varbinary"], built_in:["current_catalog","current_date","current_default_transform_group","current_path","current_role","current_schema","current_transform_group_for_type","current_user","session_user","system_time","system_user","current_time","localtime","current_timestamp","localtimestamp"] -},contains:[{begin:n.either(...s),relevance:0,keywords:{$pattern:/[\w\.]+/, -keyword:l.concat(s),literal:a,type:i}},{className:"type", -begin:n.either("double precision","large object","with timezone","without timezone") -},c,{className:"variable",begin:/@[a-z0-9][a-z0-9_]*/},{className:"string", -variants:[{begin:/'/,end:/'/,contains:[{begin:/''/}]}]},{begin:/"/,end:/"/, -contains:[{begin:/""/}]},e.C_NUMBER_MODE,e.C_BLOCK_COMMENT_MODE,t,{ -className:"operator",begin:/[-+*/=%^~]|&&?|\|\|?|!=?|<(?:=>?|<|>)?|>[>=]?/, -relevance:0}]}},grmr_swift:e=>{const n={match:/\s+/,relevance:0 -},t=e.COMMENT("/\\*","\\*/",{contains:["self"]}),a=[e.C_LINE_COMMENT_MODE,t],i={ -match:[/\./,m(...xe,...Me)],className:{2:"keyword"}},r={match:b(/\./,m(...Ae)), -relevance:0},s=Ae.filter((e=>"string"==typeof e)).concat(["_|0"]),o={variants:[{ +},contains:[{scope:"type", +match:o(["double precision","large object","with timezone","without timezone"]) +},l,s,{scope:"variable",match:/@[a-z0-9][a-z0-9_]*/},{scope:"string",variants:[{ +begin:/'/,end:/'/,contains:[{match:/''/}]}]},{begin:/"/,end:/"/,contains:[{ +match:/""/}]},e.C_NUMBER_MODE,e.C_BLOCK_COMMENT_MODE,t,{scope:"operator", +match:/[-+*/=%^~]|&&?|\|\|?|!=?|<(?:=>?|<|>)?|>[>=]?/,relevance:0}]}}, +grmr_swift:e=>{const n={match:/\s+/,relevance:0},t=e.COMMENT("/\\*","\\*/",{ +contains:["self"]}),a=[e.C_LINE_COMMENT_MODE,t],i={match:[/\./,m(...ke,...xe)], +className:{2:"keyword"}},r={match:b(/\./,m(...Me)),relevance:0 +},s=Me.filter((e=>"string"==typeof e)).concat(["_|0"]),o={variants:[{ className:"keyword", -match:m(...Ae.filter((e=>"string"!=typeof e)).concat(Se).map(ke),...Me)}]},l={ -$pattern:m(/\b\w+/,/#\w+/),keyword:s.concat(Re),literal:Ce},c=[i,r,o],g=[{ -match:b(/\./,m(...De)),relevance:0},{className:"built_in", -match:b(/\b/,m(...De),/(?=\()/)}],u={match:/->/,relevance:0},p=[u,{ -className:"operator",relevance:0,variants:[{match:Be},{match:`\\.(\\.|${Le})+`}] +match:m(...Me.filter((e=>"string"!=typeof e)).concat(Oe).map(Ne),...xe)}]},l={ +$pattern:m(/\b\w+/,/#\w+/),keyword:s.concat(Ce),literal:Ae},c=[i,r,o],g=[{ +match:b(/\./,m(...Te)),relevance:0},{className:"built_in", +match:b(/\b/,m(...Te),/(?=\()/)}],u={match:/->/,relevance:0},p=[u,{ +className:"operator",relevance:0,variants:[{match:Ie},{match:`\\.(\\.|${De})+`}] }],_="([0-9]_*)+",h="([0-9a-fA-F]_*)+",f={className:"number",relevance:0, variants:[{match:`\\b(${_})(\\.(${_}))?([eE][+-]?(${_}))?\\b`},{ match:`\\b0x(${h})(\\.(${h}))?([pP][+-]?(${_}))?\\b`},{match:/\b0o([0-7]_*)+\b/ },{match:/\b0b([01]_*)+\b/}]},E=(e="")=>({className:"subst",variants:[{ match:b(/\\/,e,/[0\\tnr"']/)},{match:b(/\\/,e,/u\{[0-9a-fA-F]{1,8}\}/)}] }),y=(e="")=>({className:"subst",match:b(/\\/,e,/[\t ]*(?:[\r\n]|\r\n)/) -}),N=(e="")=>({className:"subst",label:"interpol",begin:b(/\\/,e,/\(/),end:/\)/ -}),w=(e="")=>({begin:b(e,/"""/),end:b(/"""/,e),contains:[E(e),y(e),N(e)] -}),v=(e="")=>({begin:b(e,/"/),end:b(/"/,e),contains:[E(e),N(e)]}),O={ +}),w=(e="")=>({className:"subst",label:"interpol",begin:b(/\\/,e,/\(/),end:/\)/ +}),v=(e="")=>({begin:b(e,/"""/),end:b(/"""/,e),contains:[E(e),y(e),w(e)] +}),N=(e="")=>({begin:b(e,/"/),end:b(/"/,e),contains:[E(e),w(e)]}),k={ className:"string", -variants:[w(),w("#"),w("##"),w("###"),v(),v("#"),v("##"),v("###")] -},k=[e.BACKSLASH_ESCAPE,{begin:/\[/,end:/\]/,relevance:0, -contains:[e.BACKSLASH_ESCAPE]}],x={begin:/\/[^\s](?=[^/\n]*\/)/,end:/\//, -contains:k},M=e=>{const n=b(e,/\//),t=b(/\//,e);return{begin:n,end:t, -contains:[...k,{scope:"comment",begin:`#(?!.*${t})`,end:/$/}]}},S={ -scope:"regexp",variants:[M("###"),M("##"),M("#"),x]},A={match:b(/`/,Fe,/`/) -},C=[A,{className:"variable",match:/\$\d+/},{className:"variable", -match:`\\$${ze}+`}],T=[{match:/(@|#(un)?)available/,scope:"keyword",starts:{ -contains:[{begin:/\(/,end:/\)/,keywords:Pe,contains:[...p,f,O]}]}},{ -scope:"keyword",match:b(/@/,m(...je))},{scope:"meta",match:b(/@/,Fe)}],R={ -match:d(/\b[A-Z]/),relevance:0,contains:[{className:"type", -match:b(/(AV|CA|CF|CG|CI|CL|CM|CN|CT|MK|MP|MTK|MTL|NS|SCN|SK|UI|WK|XC)/,ze,"+") -},{className:"type",match:Ue,relevance:0},{match:/[?!]+/,relevance:0},{ -match:/\.\.\./,relevance:0},{match:b(/\s+&\s+/,d(Ue)),relevance:0}]},D={ +variants:[v(),v("#"),v("##"),v("###"),N(),N("#"),N("##"),N("###")] +},x=[e.BACKSLASH_ESCAPE,{begin:/\[/,end:/\]/,relevance:0, +contains:[e.BACKSLASH_ESCAPE]}],O={begin:/\/[^\s](?=[^/\n]*\/)/,end:/\//, +contains:x},M=e=>{const n=b(e,/\//),t=b(/\//,e);return{begin:n,end:t, +contains:[...x,{scope:"comment",begin:`#(?!.*${t})`,end:/$/}]}},A={ +scope:"regexp",variants:[M("###"),M("##"),M("#"),O]},S={match:b(/`/,$e,/`/) +},C=[S,{className:"variable",match:/\$\d+/},{className:"variable", +match:`\\$${Be}+`}],T=[{match:/(@|#(un)?)available/,scope:"keyword",starts:{ +contains:[{begin:/\(/,end:/\)/,keywords:je,contains:[...p,f,k]}]}},{ +scope:"keyword",match:b(/@/,m(...ze),d(m(/\(/,/\s+/)))},{scope:"meta", +match:b(/@/,$e)}],R={match:d(/\b[A-Z]/),relevance:0,contains:[{className:"type", +match:b(/(AV|CA|CF|CG|CI|CL|CM|CN|CT|MK|MP|MTK|MTL|NS|SCN|SK|UI|WK|XC)/,Be,"+") +},{className:"type",match:Fe,relevance:0},{match:/[?!]+/,relevance:0},{ +match:/\.\.\./,relevance:0},{match:b(/\s+&\s+/,d(Fe)),relevance:0}]},D={ begin://,keywords:l,contains:[...a,...c,...T,u,R]};R.contains.push(D) ;const I={begin:/\(/,end:/\)/,relevance:0,keywords:l,contains:["self",{ -match:b(Fe,/\s*:/),keywords:"_|0",relevance:0 -},...a,S,...c,...g,...p,f,O,...C,...T,R]},L={begin://, +match:b($e,/\s*:/),keywords:"_|0",relevance:0 +},...a,A,...c,...g,...p,f,k,...C,...T,R]},L={begin://, keywords:"repeat each",contains:[...a,R]},B={begin:/\(/,end:/\)/,keywords:l, -contains:[{begin:m(d(b(Fe,/\s*:/)),d(b(Fe,/\s+/,Fe,/\s*:/))),end:/:/, +contains:[{begin:m(d(b($e,/\s*:/)),d(b($e,/\s+/,$e,/\s*:/))),end:/:/, relevance:0,contains:[{className:"keyword",match:/\b_\b/},{className:"params", -match:Fe}]},...a,...c,...p,f,O,...T,R,I],endsParent:!0,illegal:/["']/},$={ -match:[/(func|macro)/,/\s+/,m(A.match,Fe,Be)],className:{1:"keyword", -3:"title.function"},contains:[L,B,n],illegal:[/\[/,/%/]},z={ +match:$e}]},...a,...c,...p,f,k,...T,R,I],endsParent:!0,illegal:/["']/},$={ +match:[/(func|macro)/,/\s+/,m(S.match,$e,Ie)],className:{1:"keyword", +3:"title.function"},contains:[L,B,n],illegal:[/\[/,/%/]},F={ match:[/\b(?:subscript|init[?!]?)/,/\s*(?=[<(])/],className:{1:"keyword"}, -contains:[L,B,n],illegal:/\[|%/},F={match:[/operator/,/\s+/,Be],className:{ -1:"keyword",3:"title"}},U={begin:[/precedencegroup/,/\s+/,Ue],className:{ -1:"keyword",3:"title"},contains:[R],keywords:[...Te,...Ce],end:/}/} -;for(const e of O.variants){const n=e.contains.find((e=>"interpol"===e.label)) -;n.keywords=l;const t=[...c,...g,...p,f,O,...C];n.contains=[...t,{begin:/\(/, -end:/\)/,contains:["self",...t]}]}return{name:"Swift",keywords:l, -contains:[...a,$,z,{beginKeywords:"struct protocol class extension enum actor", -end:"\\{",excludeEnd:!0,keywords:l,contains:[e.inherit(e.TITLE_MODE,{ -className:"title.class",begin:/[A-Za-z$_][\u00C0-\u02B80-9A-Za-z$_]*/}),...c] -},F,U,{beginKeywords:"import",end:/$/,contains:[...a],relevance:0 -},S,...c,...g,...p,f,O,...C,...T,R,I]}},grmr_typescript:e=>{ -const n=Oe(e),t=_e,a=["any","void","number","boolean","string","object","never","symbol","bigint","unknown"],i={ -beginKeywords:"namespace",end:/\{/,excludeEnd:!0, -contains:[n.exports.CLASS_REFERENCE]},r={beginKeywords:"interface",end:/\{/, -excludeEnd:!0,keywords:{keyword:"interface extends",built_in:a}, -contains:[n.exports.CLASS_REFERENCE]},s={$pattern:_e, -keyword:he.concat(["type","namespace","interface","public","private","protected","implements","declare","abstract","readonly","enum","override"]), -literal:fe,built_in:ve.concat(a),"variable.language":we},o={className:"meta", -begin:"@"+t},l=(e,n,t)=>{const a=e.contains.findIndex((e=>e.label===n)) +contains:[L,B,n],illegal:/\[|%/},z={match:[/operator/,/\s+/,Ie],className:{ +1:"keyword",3:"title"}},j={begin:[/precedencegroup/,/\s+/,Fe],className:{ +1:"keyword",3:"title"},contains:[R],keywords:[...Se,...Ae],end:/}/},U={ +begin:[/(struct|protocol|class|extension|enum|actor)/,/\s+/,$e,/\s*/], +beginScope:{1:"keyword",3:"title.class"},keywords:l,contains:[L,...c,{begin:/:/, +end:/\{/,keywords:l,contains:[{scope:"title.class.inherited",match:Fe},...c], +relevance:0}]};for(const e of k.variants){ +const n=e.contains.find((e=>"interpol"===e.label));n.keywords=l +;const t=[...c,...g,...p,f,k,...C];n.contains=[...t,{begin:/\(/,end:/\)/, +contains:["self",...t]}]}return{name:"Swift",keywords:l,contains:[...a,$,F,{ +match:[/class\b/,/\s+/,/func\b/,/\s+/,/\b[A-Za-z_][A-Za-z0-9_]*\b/],scope:{ +1:"keyword",3:"keyword",5:"title.function"}},{match:[/class\b/,/\s+/,/var\b/], +scope:{1:"keyword",3:"keyword"}},U,z,j,{beginKeywords:"import",end:/$/, +contains:[...a],relevance:0},A,...c,...g,...p,f,k,...C,...T,R,I]}}, +grmr_typescript:e=>{ +const n=e.regex,t=ve(e),a=me,i=["any","void","number","boolean","string","object","never","symbol","bigint","unknown"],r={ +begin:[/namespace/,/\s+/,e.IDENT_RE],beginScope:{1:"keyword",3:"title.class"} +},s={beginKeywords:"interface",end:/\{/,excludeEnd:!0,keywords:{ +keyword:"interface extends",built_in:i},contains:[t.exports.CLASS_REFERENCE] +},o={$pattern:me, +keyword:pe.concat(["type","interface","public","private","protected","implements","declare","abstract","readonly","enum","override","satisfies"]), +literal:_e,built_in:we.concat(i),"variable.language":ye},l={className:"meta", +begin:"@"+a},c=(e,n,t)=>{const a=e.contains.findIndex((e=>e.label===n)) ;if(-1===a)throw Error("can not find mode to replace");e.contains.splice(a,1,t)} -;return Object.assign(n.keywords,s), -n.exports.PARAMS_CONTAINS.push(o),n.contains=n.contains.concat([o,i,r]), -l(n,"shebang",e.SHEBANG()),l(n,"use_strict",{className:"meta",relevance:10, +;Object.assign(t.keywords,o),t.exports.PARAMS_CONTAINS.push(l) +;const d=t.contains.find((e=>"attr"===e.scope)),g=Object.assign({},d,{ +match:n.concat(a,n.lookahead(/\s*\?:/))}) +;return t.exports.PARAMS_CONTAINS.push([t.exports.CLASS_REFERENCE,d,g]), +t.contains=t.contains.concat([l,r,s,g]), +c(t,"shebang",e.SHEBANG()),c(t,"use_strict",{className:"meta",relevance:10, begin:/^\s*['"]use strict['"]/ -}),n.contains.find((e=>"func.def"===e.label)).relevance=0,Object.assign(n,{ -name:"TypeScript",aliases:["ts","tsx","mts","cts"]}),n},grmr_vbnet:e=>{ +}),t.contains.find((e=>"func.def"===e.label)).relevance=0,Object.assign(t,{ +name:"TypeScript",aliases:["ts","tsx","mts","cts"]}),t},grmr_vbnet:e=>{ const n=e.regex,t=/\d{1,2}\/\d{1,2}\/\d{4}/,a=/\d{4}-\d{1,2}-\d{1,2}/,i=/(\d|1[012])(:\d+){0,2} *(AM|PM)/,r=/\d{1,2}(:\d{1,2}){1,2}/,s={ className:"literal",variants:[{begin:n.concat(/# */,n.either(a,t),/ *#/)},{ begin:n.concat(/# */,r,/ *#/)},{begin:n.concat(/# */,i,/ *#/)},{ @@ -1186,16 +1214,17 @@ className:"tag",begin:n.concat(/<\//,n.lookahead(n.concat(t,/>/))),contains:[{ className:"name",begin:t,relevance:0},{begin:/>/,relevance:0,endsParent:!0}]}]} },grmr_yaml:e=>{ const n="true false yes no null",t="[\\w#;/?:@&=+$,.~*'()[\\]]+",a={ -className:"string",relevance:0,variants:[{begin:/'/,end:/'/},{begin:/"/,end:/"/ -},{begin:/\S+/}],contains:[e.BACKSLASH_ESCAPE,{className:"template-variable", -variants:[{begin:/\{\{/,end:/\}\}/},{begin:/%\{/,end:/\}/}]}]},i=e.inherit(a,{ -variants:[{begin:/'/,end:/'/},{begin:/"/,end:/"/},{begin:/[^\s,{}[\]]+/}]}),r={ -end:",",endsWithParent:!0,excludeEnd:!0,keywords:n,relevance:0},s={begin:/\{/, -end:/\}/,contains:[r],illegal:"\\n",relevance:0},o={begin:"\\[",end:"\\]", -contains:[r],illegal:"\\n",relevance:0},l=[{className:"attr",variants:[{ -begin:"\\w[\\w :\\/.-]*:(?=[ \t]|$)"},{begin:'"\\w[\\w :\\/.-]*":(?=[ \t]|$)'},{ -begin:"'\\w[\\w :\\/.-]*':(?=[ \t]|$)"}]},{className:"meta",begin:"^---\\s*$", -relevance:10},{className:"string", +className:"string",relevance:0,variants:[{begin:/"/,end:/"/},{begin:/\S+/}], +contains:[e.BACKSLASH_ESCAPE,{className:"template-variable",variants:[{ +begin:/\{\{/,end:/\}\}/},{begin:/%\{/,end:/\}/}]}]},i=e.inherit(a,{variants:[{ +begin:/'/,end:/'/,contains:[{begin:/''/,relevance:0}]},{begin:/"/,end:/"/},{ +begin:/[^\s,{}[\]]+/}]}),r={end:",",endsWithParent:!0,excludeEnd:!0,keywords:n, +relevance:0},s={begin:/\{/,end:/\}/,contains:[r],illegal:"\\n",relevance:0},o={ +begin:"\\[",end:"\\]",contains:[r],illegal:"\\n",relevance:0},l=[{ +className:"attr",variants:[{begin:/[\w*@][\w*@ :()\./-]*:(?=[ \t]|$)/},{ +begin:/"[\w*@][\w*@ :()\./-]*":(?=[ \t]|$)/},{ +begin:/'[\w*@][\w*@ :()\./-]*':(?=[ \t]|$)/}]},{className:"meta", +begin:"^---\\s*$",relevance:10},{className:"string", begin:"[\\|>]([1-9]?[+-])?[ ]*\\n( +)[^ ][^\\n]*\\n(\\2[^\\n]+\\n?)*"},{ begin:"<%[%=-]?",end:"[%-]?%>",subLanguage:"ruby",excludeBegin:!0,excludeEnd:!0, relevance:0},{className:"type",begin:"!\\w+!"+t},{className:"type", @@ -1205,9 +1234,11 @@ begin:"\\*"+e.UNDERSCORE_IDENT_RE+"$"},{className:"bullet",begin:"-(?=[ ]|$)", relevance:0},e.HASH_COMMENT_MODE,{beginKeywords:n,keywords:{literal:n}},{ className:"number", begin:"\\b[0-9]{4}(-[0-9][0-9]){0,2}([Tt \\t][0-9][0-9]?(:[0-9][0-9]){2})?(\\.[0-9]*)?([ \\t])*(Z|[-+][0-9][0-9]?(:[0-9][0-9])?)?\\b" -},{className:"number",begin:e.C_NUMBER_RE+"\\b",relevance:0},s,o,a],c=[...l] +},{className:"number",begin:e.C_NUMBER_RE+"\\b",relevance:0},s,o,{ +className:"string",relevance:0,begin:/'/,end:/'/,contains:[{match:/''/, +scope:"char.escape",relevance:0}]},a],c=[...l] ;return c.pop(),c.push(i),r.contains=c,{name:"YAML",case_insensitive:!0, -aliases:["yml"],contains:l}}});const He=ae;for(const e of Object.keys(Ke)){ -const n=e.replace("grmr_","").replace("_","-");He.registerLanguage(n,Ke[e])} -return He}() +aliases:["yml"],contains:l}}});const Pe=ne;for(const e of Object.keys(Ue)){ +const n=e.replace("grmr_","").replace("_","-");Pe.registerLanguage(n,Ue[e])} +return Pe}() ;"object"==typeof exports&&"undefined"!=typeof module&&(module.exports=hljs); \ No newline at end of file diff --git a/assets/web_export/marked.min.js b/assets/web_export/marked.min.js index a91afe7..c83bf6a 100644 --- a/assets/web_export/marked.min.js +++ b/assets/web_export/marked.min.js @@ -1,6 +1,79 @@ /** - * marked v12.0.2 - a markdown parser - * Copyright (c) 2011-2024, Christopher Jeffrey. (MIT Licensed) + * marked v18.0.5 - a markdown parser + * Copyright (c) 2018-2026, MarkedJS. (MIT License) + * Copyright (c) 2011-2018, Christopher Jeffrey. (MIT License) * https://github.com/markedjs/marked */ -!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?t(exports):"function"==typeof define&&define.amd?define(["exports"],t):t((e="undefined"!=typeof globalThis?globalThis:e||self).marked={})}(this,(function(e){"use strict";function t(){return{async:!1,breaks:!1,extensions:null,gfm:!0,hooks:null,pedantic:!1,renderer:null,silent:!1,tokenizer:null,walkTokens:null}}function n(t){e.defaults=t}e.defaults={async:!1,breaks:!1,extensions:null,gfm:!0,hooks:null,pedantic:!1,renderer:null,silent:!1,tokenizer:null,walkTokens:null};const s=/[&<>"']/,r=new RegExp(s.source,"g"),i=/[<>"']|&(?!(#\d{1,7}|#[Xx][a-fA-F0-9]{1,6}|\w+);)/,l=new RegExp(i.source,"g"),o={"&":"&","<":"<",">":">",'"':""","'":"'"},a=e=>o[e];function c(e,t){if(t){if(s.test(e))return e.replace(r,a)}else if(i.test(e))return e.replace(l,a);return e}const h=/&(#(?:\d+)|(?:#x[0-9A-Fa-f]+)|(?:\w+));?/gi;function p(e){return e.replace(h,((e,t)=>"colon"===(t=t.toLowerCase())?":":"#"===t.charAt(0)?"x"===t.charAt(1)?String.fromCharCode(parseInt(t.substring(2),16)):String.fromCharCode(+t.substring(1)):""))}const u=/(^|[^\[])\^/g;function k(e,t){let n="string"==typeof e?e:e.source;t=t||"";const s={replace:(e,t)=>{let r="string"==typeof t?t:t.source;return r=r.replace(u,"$1"),n=n.replace(e,r),s},getRegex:()=>new RegExp(n,t)};return s}function g(e){try{e=encodeURI(e).replace(/%25/g,"%")}catch(e){return null}return e}const f={exec:()=>null};function d(e,t){const n=e.replace(/\|/g,((e,t,n)=>{let s=!1,r=t;for(;--r>=0&&"\\"===n[r];)s=!s;return s?"|":" |"})).split(/ \|/);let s=0;if(n[0].trim()||n.shift(),n.length>0&&!n[n.length-1].trim()&&n.pop(),t)if(n.length>t)n.splice(t);else for(;n.length0)return{type:"space",raw:t[0]}}code(e){const t=this.rules.block.code.exec(e);if(t){const e=t[0].replace(/^ {1,4}/gm,"");return{type:"code",raw:t[0],codeBlockStyle:"indented",text:this.options.pedantic?e:x(e,"\n")}}}fences(e){const t=this.rules.block.fences.exec(e);if(t){const e=t[0],n=function(e,t){const n=e.match(/^(\s+)(?:```)/);if(null===n)return t;const s=n[1];return t.split("\n").map((e=>{const t=e.match(/^\s+/);if(null===t)return e;const[n]=t;return n.length>=s.length?e.slice(s.length):e})).join("\n")}(e,t[3]||"");return{type:"code",raw:e,lang:t[2]?t[2].trim().replace(this.rules.inline.anyPunctuation,"$1"):t[2],text:n}}}heading(e){const t=this.rules.block.heading.exec(e);if(t){let e=t[2].trim();if(/#$/.test(e)){const t=x(e,"#");this.options.pedantic?e=t.trim():t&&!/ $/.test(t)||(e=t.trim())}return{type:"heading",raw:t[0],depth:t[1].length,text:e,tokens:this.lexer.inline(e)}}}hr(e){const t=this.rules.block.hr.exec(e);if(t)return{type:"hr",raw:t[0]}}blockquote(e){const t=this.rules.block.blockquote.exec(e);if(t){let e=t[0].replace(/\n {0,3}((?:=+|-+) *)(?=\n|$)/g,"\n $1");e=x(e.replace(/^ *>[ \t]?/gm,""),"\n");const n=this.lexer.state.top;this.lexer.state.top=!0;const s=this.lexer.blockTokens(e);return this.lexer.state.top=n,{type:"blockquote",raw:t[0],tokens:s,text:e}}}list(e){let t=this.rules.block.list.exec(e);if(t){let n=t[1].trim();const s=n.length>1,r={type:"list",raw:"",ordered:s,start:s?+n.slice(0,-1):"",loose:!1,items:[]};n=s?`\\d{1,9}\\${n.slice(-1)}`:`\\${n}`,this.options.pedantic&&(n=s?n:"[*+-]");const i=new RegExp(`^( {0,3}${n})((?:[\t ][^\\n]*)?(?:\\n|$))`);let l="",o="",a=!1;for(;e;){let n=!1;if(!(t=i.exec(e)))break;if(this.rules.block.hr.test(e))break;l=t[0],e=e.substring(l.length);let s=t[2].split("\n",1)[0].replace(/^\t+/,(e=>" ".repeat(3*e.length))),c=e.split("\n",1)[0],h=0;this.options.pedantic?(h=2,o=s.trimStart()):(h=t[2].search(/[^ ]/),h=h>4?1:h,o=s.slice(h),h+=t[1].length);let p=!1;if(!s&&/^ *$/.test(c)&&(l+=c+"\n",e=e.substring(c.length+1),n=!0),!n){const t=new RegExp(`^ {0,${Math.min(3,h-1)}}(?:[*+-]|\\d{1,9}[.)])((?:[ \t][^\\n]*)?(?:\\n|$))`),n=new RegExp(`^ {0,${Math.min(3,h-1)}}((?:- *){3,}|(?:_ *){3,}|(?:\\* *){3,})(?:\\n+|$)`),r=new RegExp(`^ {0,${Math.min(3,h-1)}}(?:\`\`\`|~~~)`),i=new RegExp(`^ {0,${Math.min(3,h-1)}}#`);for(;e;){const a=e.split("\n",1)[0];if(c=a,this.options.pedantic&&(c=c.replace(/^ {1,4}(?=( {4})*[^ ])/g," ")),r.test(c))break;if(i.test(c))break;if(t.test(c))break;if(n.test(e))break;if(c.search(/[^ ]/)>=h||!c.trim())o+="\n"+c.slice(h);else{if(p)break;if(s.search(/[^ ]/)>=4)break;if(r.test(s))break;if(i.test(s))break;if(n.test(s))break;o+="\n"+c}p||c.trim()||(p=!0),l+=a+"\n",e=e.substring(a.length+1),s=c.slice(h)}}r.loose||(a?r.loose=!0:/\n *\n *$/.test(l)&&(a=!0));let u,k=null;this.options.gfm&&(k=/^\[[ xX]\] /.exec(o),k&&(u="[ ] "!==k[0],o=o.replace(/^\[[ xX]\] +/,""))),r.items.push({type:"list_item",raw:l,task:!!k,checked:u,loose:!1,text:o,tokens:[]}),r.raw+=l}r.items[r.items.length-1].raw=l.trimEnd(),r.items[r.items.length-1].text=o.trimEnd(),r.raw=r.raw.trimEnd();for(let e=0;e"space"===e.type)),n=t.length>0&&t.some((e=>/\n.*\n/.test(e.raw)));r.loose=n}if(r.loose)for(let e=0;e$/,"$1").replace(this.rules.inline.anyPunctuation,"$1"):"",s=t[3]?t[3].substring(1,t[3].length-1).replace(this.rules.inline.anyPunctuation,"$1"):t[3];return{type:"def",tag:e,raw:t[0],href:n,title:s}}}table(e){const t=this.rules.block.table.exec(e);if(!t)return;if(!/[:|]/.test(t[2]))return;const n=d(t[1]),s=t[2].replace(/^\||\| *$/g,"").split("|"),r=t[3]&&t[3].trim()?t[3].replace(/\n[ \t]*$/,"").split("\n"):[],i={type:"table",raw:t[0],header:[],align:[],rows:[]};if(n.length===s.length){for(const e of s)/^ *-+: *$/.test(e)?i.align.push("right"):/^ *:-+: *$/.test(e)?i.align.push("center"):/^ *:-+ *$/.test(e)?i.align.push("left"):i.align.push(null);for(const e of n)i.header.push({text:e,tokens:this.lexer.inline(e)});for(const e of r)i.rows.push(d(e,i.header.length).map((e=>({text:e,tokens:this.lexer.inline(e)}))));return i}}lheading(e){const t=this.rules.block.lheading.exec(e);if(t)return{type:"heading",raw:t[0],depth:"="===t[2].charAt(0)?1:2,text:t[1],tokens:this.lexer.inline(t[1])}}paragraph(e){const t=this.rules.block.paragraph.exec(e);if(t){const e="\n"===t[1].charAt(t[1].length-1)?t[1].slice(0,-1):t[1];return{type:"paragraph",raw:t[0],text:e,tokens:this.lexer.inline(e)}}}text(e){const t=this.rules.block.text.exec(e);if(t)return{type:"text",raw:t[0],text:t[0],tokens:this.lexer.inline(t[0])}}escape(e){const t=this.rules.inline.escape.exec(e);if(t)return{type:"escape",raw:t[0],text:c(t[1])}}tag(e){const t=this.rules.inline.tag.exec(e);if(t)return!this.lexer.state.inLink&&/^/i.test(t[0])&&(this.lexer.state.inLink=!1),!this.lexer.state.inRawBlock&&/^<(pre|code|kbd|script)(\s|>)/i.test(t[0])?this.lexer.state.inRawBlock=!0:this.lexer.state.inRawBlock&&/^<\/(pre|code|kbd|script)(\s|>)/i.test(t[0])&&(this.lexer.state.inRawBlock=!1),{type:"html",raw:t[0],inLink:this.lexer.state.inLink,inRawBlock:this.lexer.state.inRawBlock,block:!1,text:t[0]}}link(e){const t=this.rules.inline.link.exec(e);if(t){const e=t[2].trim();if(!this.options.pedantic&&/^$/.test(e))return;const t=x(e.slice(0,-1),"\\");if((e.length-t.length)%2==0)return}else{const e=function(e,t){if(-1===e.indexOf(t[1]))return-1;let n=0;for(let s=0;s-1){const n=(0===t[0].indexOf("!")?5:4)+t[1].length+e;t[2]=t[2].substring(0,e),t[0]=t[0].substring(0,n).trim(),t[3]=""}}let n=t[2],s="";if(this.options.pedantic){const e=/^([^'"]*[^\s])\s+(['"])(.*)\2/.exec(n);e&&(n=e[1],s=e[3])}else s=t[3]?t[3].slice(1,-1):"";return n=n.trim(),/^$/.test(e)?n.slice(1):n.slice(1,-1)),b(t,{href:n?n.replace(this.rules.inline.anyPunctuation,"$1"):n,title:s?s.replace(this.rules.inline.anyPunctuation,"$1"):s},t[0],this.lexer)}}reflink(e,t){let n;if((n=this.rules.inline.reflink.exec(e))||(n=this.rules.inline.nolink.exec(e))){const e=t[(n[2]||n[1]).replace(/\s+/g," ").toLowerCase()];if(!e){const e=n[0].charAt(0);return{type:"text",raw:e,text:e}}return b(n,e,n[0],this.lexer)}}emStrong(e,t,n=""){let s=this.rules.inline.emStrongLDelim.exec(e);if(!s)return;if(s[3]&&n.match(/[\p{L}\p{N}]/u))return;if(!(s[1]||s[2]||"")||!n||this.rules.inline.punctuation.exec(n)){const n=[...s[0]].length-1;let r,i,l=n,o=0;const a="*"===s[0][0]?this.rules.inline.emStrongRDelimAst:this.rules.inline.emStrongRDelimUnd;for(a.lastIndex=0,t=t.slice(-1*e.length+n);null!=(s=a.exec(t));){if(r=s[1]||s[2]||s[3]||s[4]||s[5]||s[6],!r)continue;if(i=[...r].length,s[3]||s[4]){l+=i;continue}if((s[5]||s[6])&&n%3&&!((n+i)%3)){o+=i;continue}if(l-=i,l>0)continue;i=Math.min(i,i+l+o);const t=[...s[0]][0].length,a=e.slice(0,n+s.index+t+i);if(Math.min(n,i)%2){const e=a.slice(1,-1);return{type:"em",raw:a,text:e,tokens:this.lexer.inlineTokens(e)}}const c=a.slice(2,-2);return{type:"strong",raw:a,text:c,tokens:this.lexer.inlineTokens(c)}}}}codespan(e){const t=this.rules.inline.code.exec(e);if(t){let e=t[2].replace(/\n/g," ");const n=/[^ ]/.test(e),s=/^ /.test(e)&&/ $/.test(e);return n&&s&&(e=e.substring(1,e.length-1)),e=c(e,!0),{type:"codespan",raw:t[0],text:e}}}br(e){const t=this.rules.inline.br.exec(e);if(t)return{type:"br",raw:t[0]}}del(e){const t=this.rules.inline.del.exec(e);if(t)return{type:"del",raw:t[0],text:t[2],tokens:this.lexer.inlineTokens(t[2])}}autolink(e){const t=this.rules.inline.autolink.exec(e);if(t){let e,n;return"@"===t[2]?(e=c(t[1]),n="mailto:"+e):(e=c(t[1]),n=e),{type:"link",raw:t[0],text:e,href:n,tokens:[{type:"text",raw:e,text:e}]}}}url(e){let t;if(t=this.rules.inline.url.exec(e)){let e,n;if("@"===t[2])e=c(t[0]),n="mailto:"+e;else{let s;do{s=t[0],t[0]=this.rules.inline._backpedal.exec(t[0])?.[0]??""}while(s!==t[0]);e=c(t[0]),n="www."===t[1]?"http://"+t[0]:t[0]}return{type:"link",raw:t[0],text:e,href:n,tokens:[{type:"text",raw:e,text:e}]}}}inlineText(e){const t=this.rules.inline.text.exec(e);if(t){let e;return e=this.lexer.state.inRawBlock?t[0]:c(t[0]),{type:"text",raw:t[0],text:e}}}}const m=/^ {0,3}((?:-[\t ]*){3,}|(?:_[ \t]*){3,}|(?:\*[ \t]*){3,})(?:\n+|$)/,y=/(?:[*+-]|\d{1,9}[.)])/,$=k(/^(?!bull |blockCode|fences|blockquote|heading|html)((?:.|\n(?!\s*?\n|bull |blockCode|fences|blockquote|heading|html))+?)\n {0,3}(=+|-+) *(?:\n+|$)/).replace(/bull/g,y).replace(/blockCode/g,/ {4}/).replace(/fences/g,/ {0,3}(?:`{3,}|~{3,})/).replace(/blockquote/g,/ {0,3}>/).replace(/heading/g,/ {0,3}#{1,6}/).replace(/html/g,/ {0,3}<[^\n>]+>\n/).getRegex(),z=/^([^\n]+(?:\n(?!hr|heading|lheading|blockquote|fences|list|html|table| +\n)[^\n]+)*)/,T=/(?!\s*\])(?:\\.|[^\[\]\\])+/,R=k(/^ {0,3}\[(label)\]: *(?:\n *)?([^<\s][^\s]*|<.*?>)(?:(?: +(?:\n *)?| *\n *)(title))? *(?:\n+|$)/).replace("label",T).replace("title",/(?:"(?:\\"?|[^"\\])*"|'[^'\n]*(?:\n[^'\n]+)*\n?'|\([^()]*\))/).getRegex(),_=k(/^( {0,3}bull)([ \t][^\n]+?)?(?:\n|$)/).replace(/bull/g,y).getRegex(),A="address|article|aside|base|basefont|blockquote|body|caption|center|col|colgroup|dd|details|dialog|dir|div|dl|dt|fieldset|figcaption|figure|footer|form|frame|frameset|h[1-6]|head|header|hr|html|iframe|legend|li|link|main|menu|menuitem|meta|nav|noframes|ol|optgroup|option|p|param|search|section|summary|table|tbody|td|tfoot|th|thead|title|tr|track|ul",S=/|$))/,I=k("^ {0,3}(?:<(script|pre|style|textarea)[\\s>][\\s\\S]*?(?:[^\\n]*\\n+|$)|comment[^\\n]*(\\n+|$)|<\\?[\\s\\S]*?(?:\\?>\\n*|$)|\\n*|$)|\\n*|$)|)[\\s\\S]*?(?:(?:\\n *)+\\n|$)|<(?!script|pre|style|textarea)([a-z][\\w-]*)(?:attribute)*? */?>(?=[ \\t]*(?:\\n|$))[\\s\\S]*?(?:(?:\\n *)+\\n|$)|(?=[ \\t]*(?:\\n|$))[\\s\\S]*?(?:(?:\\n *)+\\n|$))","i").replace("comment",S).replace("tag",A).replace("attribute",/ +[a-zA-Z:_][\w.:-]*(?: *= *"[^"\n]*"| *= *'[^'\n]*'| *= *[^\s"'=<>`]+)?/).getRegex(),E=k(z).replace("hr",m).replace("heading"," {0,3}#{1,6}(?:\\s|$)").replace("|lheading","").replace("|table","").replace("blockquote"," {0,3}>").replace("fences"," {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n").replace("list"," {0,3}(?:[*+-]|1[.)]) ").replace("html",")|<(?:script|pre|style|textarea|!--)").replace("tag",A).getRegex(),q={blockquote:k(/^( {0,3}> ?(paragraph|[^\n]*)(?:\n|$))+/).replace("paragraph",E).getRegex(),code:/^( {4}[^\n]+(?:\n(?: *(?:\n|$))*)?)+/,def:R,fences:/^ {0,3}(`{3,}(?=[^`\n]*(?:\n|$))|~{3,})([^\n]*)(?:\n|$)(?:|([\s\S]*?)(?:\n|$))(?: {0,3}\1[~`]* *(?=\n|$)|$)/,heading:/^ {0,3}(#{1,6})(?=\s|$)(.*)(?:\n+|$)/,hr:m,html:I,lheading:$,list:_,newline:/^(?: *(?:\n|$))+/,paragraph:E,table:f,text:/^[^\n]+/},Z=k("^ *([^\\n ].*)\\n {0,3}((?:\\| *)?:?-+:? *(?:\\| *:?-+:? *)*(?:\\| *)?)(?:\\n((?:(?! *\\n|hr|heading|blockquote|code|fences|list|html).*(?:\\n|$))*)\\n*|$)").replace("hr",m).replace("heading"," {0,3}#{1,6}(?:\\s|$)").replace("blockquote"," {0,3}>").replace("code"," {4}[^\\n]").replace("fences"," {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n").replace("list"," {0,3}(?:[*+-]|1[.)]) ").replace("html",")|<(?:script|pre|style|textarea|!--)").replace("tag",A).getRegex(),L={...q,table:Z,paragraph:k(z).replace("hr",m).replace("heading"," {0,3}#{1,6}(?:\\s|$)").replace("|lheading","").replace("table",Z).replace("blockquote"," {0,3}>").replace("fences"," {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n").replace("list"," {0,3}(?:[*+-]|1[.)]) ").replace("html",")|<(?:script|pre|style|textarea|!--)").replace("tag",A).getRegex()},P={...q,html:k("^ *(?:comment *(?:\\n|\\s*$)|<(tag)[\\s\\S]+? *(?:\\n{2,}|\\s*$)|\\s]*)*?/?> *(?:\\n{2,}|\\s*$))").replace("comment",S).replace(/tag/g,"(?!(?:a|em|strong|small|s|cite|q|dfn|abbr|data|time|code|var|samp|kbd|sub|sup|i|b|u|mark|ruby|rt|rp|bdi|bdo|span|br|wbr|ins|del|img)\\b)\\w+(?!:|[^\\w\\s@]*@)\\b").getRegex(),def:/^ *\[([^\]]+)\]: *]+)>?(?: +(["(][^\n]+[")]))? *(?:\n+|$)/,heading:/^(#{1,6})(.*)(?:\n+|$)/,fences:f,lheading:/^(.+?)\n {0,3}(=+|-+) *(?:\n+|$)/,paragraph:k(z).replace("hr",m).replace("heading"," *#{1,6} *[^\n]").replace("lheading",$).replace("|table","").replace("blockquote"," {0,3}>").replace("|fences","").replace("|list","").replace("|html","").replace("|tag","").getRegex()},Q=/^\\([!"#$%&'()*+,\-./:;<=>?@\[\]\\^_`{|}~])/,v=/^( {2,}|\\)\n(?!\s*$)/,B="\\p{P}\\p{S}",C=k(/^((?![*_])[\spunctuation])/,"u").replace(/punctuation/g,B).getRegex(),M=k(/^(?:\*+(?:((?!\*)[punct])|[^\s*]))|^_+(?:((?!_)[punct])|([^\s_]))/,"u").replace(/punct/g,B).getRegex(),O=k("^[^_*]*?__[^_*]*?\\*[^_*]*?(?=__)|[^*]+(?=[^*])|(?!\\*)[punct](\\*+)(?=[\\s]|$)|[^punct\\s](\\*+)(?!\\*)(?=[punct\\s]|$)|(?!\\*)[punct\\s](\\*+)(?=[^punct\\s])|[\\s](\\*+)(?!\\*)(?=[punct])|(?!\\*)[punct](\\*+)(?!\\*)(?=[punct])|[^punct\\s](\\*+)(?=[^punct\\s])","gu").replace(/punct/g,B).getRegex(),D=k("^[^_*]*?\\*\\*[^_*]*?_[^_*]*?(?=\\*\\*)|[^_]+(?=[^_])|(?!_)[punct](_+)(?=[\\s]|$)|[^punct\\s](_+)(?!_)(?=[punct\\s]|$)|(?!_)[punct\\s](_+)(?=[^punct\\s])|[\\s](_+)(?!_)(?=[punct])|(?!_)[punct](_+)(?!_)(?=[punct])","gu").replace(/punct/g,B).getRegex(),j=k(/\\([punct])/,"gu").replace(/punct/g,B).getRegex(),H=k(/^<(scheme:[^\s\x00-\x1f<>]*|email)>/).replace("scheme",/[a-zA-Z][a-zA-Z0-9+.-]{1,31}/).replace("email",/[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+(@)[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+(?![-_])/).getRegex(),U=k(S).replace("(?:--\x3e|$)","--\x3e").getRegex(),X=k("^comment|^|^<[a-zA-Z][\\w-]*(?:attribute)*?\\s*/?>|^<\\?[\\s\\S]*?\\?>|^|^").replace("comment",U).replace("attribute",/\s+[a-zA-Z:_][\w.:-]*(?:\s*=\s*"[^"]*"|\s*=\s*'[^']*'|\s*=\s*[^\s"'=<>`]+)?/).getRegex(),F=/(?:\[(?:\\.|[^\[\]\\])*\]|\\.|`[^`]*`|[^\[\]\\`])*?/,N=k(/^!?\[(label)\]\(\s*(href)(?:\s+(title))?\s*\)/).replace("label",F).replace("href",/<(?:\\.|[^\n<>\\])+>|[^\s\x00-\x1f]*/).replace("title",/"(?:\\"?|[^"\\])*"|'(?:\\'?|[^'\\])*'|\((?:\\\)?|[^)\\])*\)/).getRegex(),G=k(/^!?\[(label)\]\[(ref)\]/).replace("label",F).replace("ref",T).getRegex(),J=k(/^!?\[(ref)\](?:\[\])?/).replace("ref",T).getRegex(),K={_backpedal:f,anyPunctuation:j,autolink:H,blockSkip:/\[[^[\]]*?\]\([^\(\)]*?\)|`[^`]*?`|<[^<>]*?>/g,br:v,code:/^(`+)([^`]|[^`][\s\S]*?[^`])\1(?!`)/,del:f,emStrongLDelim:M,emStrongRDelimAst:O,emStrongRDelimUnd:D,escape:Q,link:N,nolink:J,punctuation:C,reflink:G,reflinkSearch:k("reflink|nolink(?!\\()","g").replace("reflink",G).replace("nolink",J).getRegex(),tag:X,text:/^(`+|[^`])(?:(?= {2,}\n)|[\s\S]*?(?:(?=[\\t+" ".repeat(n.length)));e;)if(!(this.options.extensions&&this.options.extensions.block&&this.options.extensions.block.some((s=>!!(n=s.call({lexer:this},e,t))&&(e=e.substring(n.raw.length),t.push(n),!0)))))if(n=this.tokenizer.space(e))e=e.substring(n.raw.length),1===n.raw.length&&t.length>0?t[t.length-1].raw+="\n":t.push(n);else if(n=this.tokenizer.code(e))e=e.substring(n.raw.length),s=t[t.length-1],!s||"paragraph"!==s.type&&"text"!==s.type?t.push(n):(s.raw+="\n"+n.raw,s.text+="\n"+n.text,this.inlineQueue[this.inlineQueue.length-1].src=s.text);else if(n=this.tokenizer.fences(e))e=e.substring(n.raw.length),t.push(n);else if(n=this.tokenizer.heading(e))e=e.substring(n.raw.length),t.push(n);else if(n=this.tokenizer.hr(e))e=e.substring(n.raw.length),t.push(n);else if(n=this.tokenizer.blockquote(e))e=e.substring(n.raw.length),t.push(n);else if(n=this.tokenizer.list(e))e=e.substring(n.raw.length),t.push(n);else if(n=this.tokenizer.html(e))e=e.substring(n.raw.length),t.push(n);else if(n=this.tokenizer.def(e))e=e.substring(n.raw.length),s=t[t.length-1],!s||"paragraph"!==s.type&&"text"!==s.type?this.tokens.links[n.tag]||(this.tokens.links[n.tag]={href:n.href,title:n.title}):(s.raw+="\n"+n.raw,s.text+="\n"+n.raw,this.inlineQueue[this.inlineQueue.length-1].src=s.text);else if(n=this.tokenizer.table(e))e=e.substring(n.raw.length),t.push(n);else if(n=this.tokenizer.lheading(e))e=e.substring(n.raw.length),t.push(n);else{if(r=e,this.options.extensions&&this.options.extensions.startBlock){let t=1/0;const n=e.slice(1);let s;this.options.extensions.startBlock.forEach((e=>{s=e.call({lexer:this},n),"number"==typeof s&&s>=0&&(t=Math.min(t,s))})),t<1/0&&t>=0&&(r=e.substring(0,t+1))}if(this.state.top&&(n=this.tokenizer.paragraph(r)))s=t[t.length-1],i&&"paragraph"===s.type?(s.raw+="\n"+n.raw,s.text+="\n"+n.text,this.inlineQueue.pop(),this.inlineQueue[this.inlineQueue.length-1].src=s.text):t.push(n),i=r.length!==e.length,e=e.substring(n.raw.length);else if(n=this.tokenizer.text(e))e=e.substring(n.raw.length),s=t[t.length-1],s&&"text"===s.type?(s.raw+="\n"+n.raw,s.text+="\n"+n.text,this.inlineQueue.pop(),this.inlineQueue[this.inlineQueue.length-1].src=s.text):t.push(n);else if(e){const t="Infinite loop on byte: "+e.charCodeAt(0);if(this.options.silent){console.error(t);break}throw new Error(t)}}return this.state.top=!0,t}inline(e,t=[]){return this.inlineQueue.push({src:e,tokens:t}),t}inlineTokens(e,t=[]){let n,s,r,i,l,o,a=e;if(this.tokens.links){const e=Object.keys(this.tokens.links);if(e.length>0)for(;null!=(i=this.tokenizer.rules.inline.reflinkSearch.exec(a));)e.includes(i[0].slice(i[0].lastIndexOf("[")+1,-1))&&(a=a.slice(0,i.index)+"["+"a".repeat(i[0].length-2)+"]"+a.slice(this.tokenizer.rules.inline.reflinkSearch.lastIndex))}for(;null!=(i=this.tokenizer.rules.inline.blockSkip.exec(a));)a=a.slice(0,i.index)+"["+"a".repeat(i[0].length-2)+"]"+a.slice(this.tokenizer.rules.inline.blockSkip.lastIndex);for(;null!=(i=this.tokenizer.rules.inline.anyPunctuation.exec(a));)a=a.slice(0,i.index)+"++"+a.slice(this.tokenizer.rules.inline.anyPunctuation.lastIndex);for(;e;)if(l||(o=""),l=!1,!(this.options.extensions&&this.options.extensions.inline&&this.options.extensions.inline.some((s=>!!(n=s.call({lexer:this},e,t))&&(e=e.substring(n.raw.length),t.push(n),!0)))))if(n=this.tokenizer.escape(e))e=e.substring(n.raw.length),t.push(n);else if(n=this.tokenizer.tag(e))e=e.substring(n.raw.length),s=t[t.length-1],s&&"text"===n.type&&"text"===s.type?(s.raw+=n.raw,s.text+=n.text):t.push(n);else if(n=this.tokenizer.link(e))e=e.substring(n.raw.length),t.push(n);else if(n=this.tokenizer.reflink(e,this.tokens.links))e=e.substring(n.raw.length),s=t[t.length-1],s&&"text"===n.type&&"text"===s.type?(s.raw+=n.raw,s.text+=n.text):t.push(n);else if(n=this.tokenizer.emStrong(e,a,o))e=e.substring(n.raw.length),t.push(n);else if(n=this.tokenizer.codespan(e))e=e.substring(n.raw.length),t.push(n);else if(n=this.tokenizer.br(e))e=e.substring(n.raw.length),t.push(n);else if(n=this.tokenizer.del(e))e=e.substring(n.raw.length),t.push(n);else if(n=this.tokenizer.autolink(e))e=e.substring(n.raw.length),t.push(n);else if(this.state.inLink||!(n=this.tokenizer.url(e))){if(r=e,this.options.extensions&&this.options.extensions.startInline){let t=1/0;const n=e.slice(1);let s;this.options.extensions.startInline.forEach((e=>{s=e.call({lexer:this},n),"number"==typeof s&&s>=0&&(t=Math.min(t,s))})),t<1/0&&t>=0&&(r=e.substring(0,t+1))}if(n=this.tokenizer.inlineText(r))e=e.substring(n.raw.length),"_"!==n.raw.slice(-1)&&(o=n.raw.slice(-1)),l=!0,s=t[t.length-1],s&&"text"===s.type?(s.raw+=n.raw,s.text+=n.text):t.push(n);else if(e){const t="Infinite loop on byte: "+e.charCodeAt(0);if(this.options.silent){console.error(t);break}throw new Error(t)}}else e=e.substring(n.raw.length),t.push(n);return t}}class se{options;constructor(t){this.options=t||e.defaults}code(e,t,n){const s=(t||"").match(/^\S*/)?.[0];return e=e.replace(/\n$/,"")+"\n",s?'
'+(n?e:c(e,!0))+"
\n":"
"+(n?e:c(e,!0))+"
\n"}blockquote(e){return`
\n${e}
\n`}html(e,t){return e}heading(e,t,n){return`${e}\n`}hr(){return"
\n"}list(e,t,n){const s=t?"ol":"ul";return"<"+s+(t&&1!==n?' start="'+n+'"':"")+">\n"+e+"\n"}listitem(e,t,n){return`
  • ${e}
  • \n`}checkbox(e){return"'}paragraph(e){return`

    ${e}

    \n`}table(e,t){return t&&(t=`${t}`),"\n\n"+e+"\n"+t+"
    \n"}tablerow(e){return`\n${e}\n`}tablecell(e,t){const n=t.header?"th":"td";return(t.align?`<${n} align="${t.align}">`:`<${n}>`)+e+`\n`}strong(e){return`${e}`}em(e){return`${e}`}codespan(e){return`${e}`}br(){return"
    "}del(e){return`${e}`}link(e,t,n){const s=g(e);if(null===s)return n;let r='
    ",r}image(e,t,n){const s=g(e);if(null===s)return n;let r=`${n}0&&"paragraph"===n.tokens[0].type?(n.tokens[0].text=e+" "+n.tokens[0].text,n.tokens[0].tokens&&n.tokens[0].tokens.length>0&&"text"===n.tokens[0].tokens[0].type&&(n.tokens[0].tokens[0].text=e+" "+n.tokens[0].tokens[0].text)):n.tokens.unshift({type:"text",text:e+" "}):o+=e+" "}o+=this.parse(n.tokens,i),l+=this.renderer.listitem(o,r,!!s)}n+=this.renderer.list(l,t,s);continue}case"html":{const e=r;n+=this.renderer.html(e.text,e.block);continue}case"paragraph":{const e=r;n+=this.renderer.paragraph(this.parseInline(e.tokens));continue}case"text":{let i=r,l=i.tokens?this.parseInline(i.tokens):i.text;for(;s+1{const r=e[s].flat(1/0);n=n.concat(this.walkTokens(r,t))})):e.tokens&&(n=n.concat(this.walkTokens(e.tokens,t)))}}return n}use(...e){const t=this.defaults.extensions||{renderers:{},childTokens:{}};return e.forEach((e=>{const n={...e};if(n.async=this.defaults.async||n.async||!1,e.extensions&&(e.extensions.forEach((e=>{if(!e.name)throw new Error("extension name required");if("renderer"in e){const n=t.renderers[e.name];t.renderers[e.name]=n?function(...t){let s=e.renderer.apply(this,t);return!1===s&&(s=n.apply(this,t)),s}:e.renderer}if("tokenizer"in e){if(!e.level||"block"!==e.level&&"inline"!==e.level)throw new Error("extension level must be 'block' or 'inline'");const n=t[e.level];n?n.unshift(e.tokenizer):t[e.level]=[e.tokenizer],e.start&&("block"===e.level?t.startBlock?t.startBlock.push(e.start):t.startBlock=[e.start]:"inline"===e.level&&(t.startInline?t.startInline.push(e.start):t.startInline=[e.start]))}"childTokens"in e&&e.childTokens&&(t.childTokens[e.name]=e.childTokens)})),n.extensions=t),e.renderer){const t=this.defaults.renderer||new se(this.defaults);for(const n in e.renderer){if(!(n in t))throw new Error(`renderer '${n}' does not exist`);if("options"===n)continue;const s=n,r=e.renderer[s],i=t[s];t[s]=(...e)=>{let n=r.apply(t,e);return!1===n&&(n=i.apply(t,e)),n||""}}n.renderer=t}if(e.tokenizer){const t=this.defaults.tokenizer||new w(this.defaults);for(const n in e.tokenizer){if(!(n in t))throw new Error(`tokenizer '${n}' does not exist`);if(["options","rules","lexer"].includes(n))continue;const s=n,r=e.tokenizer[s],i=t[s];t[s]=(...e)=>{let n=r.apply(t,e);return!1===n&&(n=i.apply(t,e)),n}}n.tokenizer=t}if(e.hooks){const t=this.defaults.hooks||new le;for(const n in e.hooks){if(!(n in t))throw new Error(`hook '${n}' does not exist`);if("options"===n)continue;const s=n,r=e.hooks[s],i=t[s];le.passThroughHooks.has(n)?t[s]=e=>{if(this.defaults.async)return Promise.resolve(r.call(t,e)).then((e=>i.call(t,e)));const n=r.call(t,e);return i.call(t,n)}:t[s]=(...e)=>{let n=r.apply(t,e);return!1===n&&(n=i.apply(t,e)),n}}n.hooks=t}if(e.walkTokens){const t=this.defaults.walkTokens,s=e.walkTokens;n.walkTokens=function(e){let n=[];return n.push(s.call(this,e)),t&&(n=n.concat(t.call(this,e))),n}}this.defaults={...this.defaults,...n}})),this}setOptions(e){return this.defaults={...this.defaults,...e},this}lexer(e,t){return ne.lex(e,t??this.defaults)}parser(e,t){return ie.parse(e,t??this.defaults)}#e(e,t){return(n,s)=>{const r={...s},i={...this.defaults,...r};!0===this.defaults.async&&!1===r.async&&(i.silent||console.warn("marked(): The async option was set to true by an extension. The async: false option sent to parse will be ignored."),i.async=!0);const l=this.#t(!!i.silent,!!i.async);if(null==n)return l(new Error("marked(): input parameter is undefined or null"));if("string"!=typeof n)return l(new Error("marked(): input parameter is of type "+Object.prototype.toString.call(n)+", string expected"));if(i.hooks&&(i.hooks.options=i),i.async)return Promise.resolve(i.hooks?i.hooks.preprocess(n):n).then((t=>e(t,i))).then((e=>i.hooks?i.hooks.processAllTokens(e):e)).then((e=>i.walkTokens?Promise.all(this.walkTokens(e,i.walkTokens)).then((()=>e)):e)).then((e=>t(e,i))).then((e=>i.hooks?i.hooks.postprocess(e):e)).catch(l);try{i.hooks&&(n=i.hooks.preprocess(n));let s=e(n,i);i.hooks&&(s=i.hooks.processAllTokens(s)),i.walkTokens&&this.walkTokens(s,i.walkTokens);let r=t(s,i);return i.hooks&&(r=i.hooks.postprocess(r)),r}catch(e){return l(e)}}}#t(e,t){return n=>{if(n.message+="\nPlease report this to https://github.com/markedjs/marked.",e){const e="

    An error occurred:

    "+c(n.message+"",!0)+"
    ";return t?Promise.resolve(e):e}if(t)return Promise.reject(n);throw n}}}const ae=new oe;function ce(e,t){return ae.parse(e,t)}ce.options=ce.setOptions=function(e){return ae.setOptions(e),ce.defaults=ae.defaults,n(ce.defaults),ce},ce.getDefaults=t,ce.defaults=e.defaults,ce.use=function(...e){return ae.use(...e),ce.defaults=ae.defaults,n(ce.defaults),ce},ce.walkTokens=function(e,t){return ae.walkTokens(e,t)},ce.parseInline=ae.parseInline,ce.Parser=ie,ce.parser=ie.parse,ce.Renderer=se,ce.TextRenderer=re,ce.Lexer=ne,ce.lexer=ne.lex,ce.Tokenizer=w,ce.Hooks=le,ce.parse=ce;const he=ce.options,pe=ce.setOptions,ue=ce.use,ke=ce.walkTokens,ge=ce.parseInline,fe=ce,de=ie.parse,xe=ne.lex;e.Hooks=le,e.Lexer=ne,e.Marked=oe,e.Parser=ie,e.Renderer=se,e.TextRenderer=re,e.Tokenizer=w,e.getDefaults=t,e.lexer=xe,e.marked=ce,e.options=he,e.parse=fe,e.parseInline=ge,e.parser=de,e.setOptions=pe,e.use=ue,e.walkTokens=ke})); + +/** + * DO NOT EDIT THIS FILE + * The code in this file is generated from files in ./src/ + */ +(function(g,f){if(typeof exports=="object"&&typeof module<"u"){module.exports=f()}else if("function"==typeof define && define.amd){define("marked",f)}else {g["marked"]=f()}}(typeof globalThis < "u" ? globalThis : typeof self < "u" ? self : this,function(){var exports={};var __exports=exports;var module={exports}; +"use strict";var N=Object.defineProperty;var Oe=Object.getOwnPropertyDescriptor;var we=Object.getOwnPropertyNames;var ye=Object.prototype.hasOwnProperty;var Pe=(l,e)=>{for(var t in e)N(l,t,{get:e[t],enumerable:!0})},Se=(l,e,t,n)=>{if(e&&typeof e=="object"||typeof e=="function")for(let s of we(e))!ye.call(l,s)&&s!==t&&N(l,s,{get:()=>e[s],enumerable:!(n=Oe(e,s))||n.enumerable});return l};var $e=l=>Se(N({},"__esModule",{value:!0}),l);var Rt={};Pe(Rt,{Hooks:()=>P,Lexer:()=>x,Marked:()=>C,Parser:()=>b,Renderer:()=>y,TextRenderer:()=>S,Tokenizer:()=>w,defaults:()=>T,getDefaults:()=>_,lexer:()=>bt,marked:()=>g,options:()=>ht,parse:()=>mt,parseInline:()=>ft,parser:()=>xt,setOptions:()=>kt,use:()=>dt,walkTokens:()=>gt});module.exports=$e(Rt);function _(){return{async:!1,breaks:!1,extensions:null,gfm:!0,hooks:null,pedantic:!1,renderer:null,silent:!1,tokenizer:null,walkTokens:null}}var T=_();function Q(l){T=l}var z={exec:()=>null};function E(l){let e=[];return t=>{let n=Math.max(0,Math.min(3,t-1)),s=e[n];return s||(s=l(n),e[n]=s),s}}function d(l,e=""){let t=typeof l=="string"?l:l.source,n={replace:(s,r)=>{let i=typeof r=="string"?r:r.source;return i=i.replace(m.caret,"$1"),t=t.replace(s,i),n},getRegex:()=>new RegExp(t,e)};return n}var Le=((l="")=>{try{return!!new RegExp("(?<=1)(?/,blockquoteSetextReplace:/\n {0,3}((?:=+|-+) *)(?=\n|$)/g,blockquoteSetextReplace2:/^ {0,3}>[ \t]?/gm,listReplaceNesting:/^ {1,4}(?=( {4})*[^ ])/g,listIsTask:/^\[[ xX]\] +\S/,listReplaceTask:/^\[[ xX]\] +/,listTaskCheckbox:/\[[ xX]\]/,anyLine:/\n.*\n/,hrefBrackets:/^<(.*)>$/,tableDelimiter:/[:|]/,tableAlignChars:/^\||\| *$/g,tableRowBlankLine:/\n[ \t]*$/,tableAlignRight:/^ *-+: *$/,tableAlignCenter:/^ *:-+: *$/,tableAlignLeft:/^ *:-+ *$/,startATag:/^
    /i,startPreScriptTag:/^<(pre|code|kbd|script)(\s|>)/i,endPreScriptTag:/^<\/(pre|code|kbd|script)(\s|>)/i,startAngleBracket:/^$/,pedanticHrefTitle:/^([^'"]*[^\s])\s+(['"])(.*)\2/,unicodeAlphaNumeric:/[\p{L}\p{N}]/u,escapeTest:/[&<>"']/,escapeReplace:/[&<>"']/g,escapeTestNoEncode:/[<>"']|&(?!(#\d{1,7}|#[Xx][a-fA-F0-9]{1,6}|\w+);)/,escapeReplaceNoEncode:/[<>"']|&(?!(#\d{1,7}|#[Xx][a-fA-F0-9]{1,6}|\w+);)/g,caret:/(^|[^\[])\^/g,percentDecode:/%25/g,findPipe:/\|/g,splitPipe:/ \|/,slashPipe:/\\\|/g,carriageReturn:/\r\n|\r/g,spaceLine:/^ +$/gm,notSpaceStart:/^\S*/,endingNewline:/\n$/,listItemRegex:l=>new RegExp(`^( {0,3}${l})((?:[ ][^\\n]*)?(?:\\n|$))`),nextBulletRegex:E(l=>new RegExp(`^ {0,${l}}(?:[*+-]|\\d{1,9}[.)])((?:[ ][^\\n]*)?(?:\\n|$))`)),hrRegex:E(l=>new RegExp(`^ {0,${l}}((?:- *){3,}|(?:_ *){3,}|(?:\\* *){3,})(?:\\n+|$)`)),fencesBeginRegex:E(l=>new RegExp(`^ {0,${l}}(?:\`\`\`|~~~)`)),headingBeginRegex:E(l=>new RegExp(`^ {0,${l}}#`)),htmlBeginRegex:E(l=>new RegExp(`^ {0,${l}}<(?:[a-z].*>|!--)`,"i")),blockquoteBeginRegex:E(l=>new RegExp(`^ {0,${l}}>`))},_e=/^(?:[ \t]*(?:\n|$))+/,ze=/^((?: {4}| {0,3}\t)[^\n]+(?:\n(?:[ \t]*(?:\n|$))*)?)+/,Me=/^ {0,3}(`{3,}(?=[^`\n]*(?:\n|$))|~{3,})([^\n]*)(?:\n|$)(?:|([\s\S]*?)(?:\n|$))(?: {0,3}\1[~`]* *(?=\n|$)|$)/,D=/^ {0,3}((?:-[\t ]*){3,}|(?:_[ \t]*){3,}|(?:\*[ \t]*){3,})(?:\n+|$)/,Ee=/^ {0,3}(#{1,6})(?=\s|$)(.*)(?:\n+|$)/,F=/ {0,3}(?:[*+-]|\d{1,9}[.)])/,ae=/^(?!bull |blockCode|fences|blockquote|heading|html|table)((?:.|\n(?!\s*?\n|bull |blockCode|fences|blockquote|heading|html|table))+?)\n {0,3}(=+|-+) *(?:\n+|$)/,le=d(ae).replace(/bull/g,F).replace(/blockCode/g,/(?: {4}| {0,3}\t)/).replace(/fences/g,/ {0,3}(?:`{3,}|~{3,})/).replace(/blockquote/g,/ {0,3}>/).replace(/heading/g,/ {0,3}#{1,6}/).replace(/html/g,/ {0,3}<[^\n>]+>\n/).replace(/\|table/g,"").getRegex(),Ie=d(ae).replace(/bull/g,F).replace(/blockCode/g,/(?: {4}| {0,3}\t)/).replace(/fences/g,/ {0,3}(?:`{3,}|~{3,})/).replace(/blockquote/g,/ {0,3}>/).replace(/heading/g,/ {0,3}#{1,6}/).replace(/html/g,/ {0,3}<[^\n>]+>\n/).replace(/table/g,/ {0,3}\|?(?:[:\- ]*\|)+[\:\- ]*\n/).getRegex(),U=/^([^\n]+(?:\n(?!hr|heading|lheading|blockquote|fences|list|html|table| +\n)[^\n]+)*)/,Ae=/^[^\n]+/,K=/(?!\s*\])(?:\\[\s\S]|[^\[\]\\])+/,Ce=d(/^ {0,3}\[(label)\]: *(?:\n[ \t]*)?([^<\s][^\s]*|<.*?>)(?:(?: +(?:\n[ \t]*)?| *\n[ \t]*)(title))? *(?:\n+|$)/).replace("label",K).replace("title",/(?:"(?:\\"?|[^"\\])*"|'[^'\n]*(?:\n[^'\n]+)*\n?'|\([^()]*\))/).getRegex(),Be=d(/^(bull)([ \t][^\n]*?)?(?:\n|$)/).replace(/bull/g,F).getRegex(),H="address|article|aside|base|basefont|blockquote|body|caption|center|col|colgroup|dd|details|dialog|dir|div|dl|dt|fieldset|figcaption|figure|footer|form|frame|frameset|h[1-6]|head|header|hr|html|iframe|legend|li|link|main|menu|menuitem|meta|nav|noframes|ol|optgroup|option|p|param|search|section|summary|table|tbody|td|tfoot|th|thead|title|tr|track|ul",W=/|$))/,De=d("^ {0,3}(?:<(script|pre|style|textarea)[\\s>][\\s\\S]*?(?:[^\\n]*\\n+|$)|comment[^\\n]*(\\n+|$)|<\\?[\\s\\S]*?(?:\\?>\\n*|$)|\\n*|$)|\\n*|$)|)[\\s\\S]*?(?:(?:\\n[ ]*)+\\n|$)|<(?!script|pre|style|textarea)([a-z][\\w-]*)(?:attribute)*? */?>(?=[ \\t]*(?:\\n|$))[\\s\\S]*?(?:(?:\\n[ ]*)+\\n|$)|(?=[ \\t]*(?:\\n|$))[\\s\\S]*?(?:(?:\\n[ ]*)+\\n|$))","i").replace("comment",W).replace("tag",H).replace("attribute",/ +[a-zA-Z:_][\w.:-]*(?: *= *"[^"\n]*"| *= *'[^'\n]*'| *= *[^\s"'=<>`]+)?/).getRegex(),ue=d(U).replace("hr",D).replace("heading"," {0,3}#{1,6}(?:\\s|$)").replace("|lheading","").replace("|table","").replace("blockquote"," {0,3}>").replace("fences"," {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n").replace("list"," {0,3}(?:[*+-]|1[.)])[ \\t]+[^ \\t\\n]").replace("html",")|<(?:script|pre|style|textarea|!--)").replace("tag",H).getRegex(),qe=d(/^( {0,3}> ?(paragraph|[^\n]*)(?:\n|$))+/).replace("paragraph",ue).getRegex(),X={blockquote:qe,code:ze,def:Ce,fences:Me,heading:Ee,hr:D,html:De,lheading:le,list:Be,newline:_e,paragraph:ue,table:z,text:Ae},ie=d("^ *([^\\n ].*)\\n {0,3}((?:\\| *)?:?-+:? *(?:\\| *:?-+:? *)*(?:\\| *)?)(?:\\n((?:(?! *\\n|hr|heading|blockquote|code|fences|list|html).*(?:\\n|$))*)\\n*|$)").replace("hr",D).replace("heading"," {0,3}#{1,6}(?:\\s|$)").replace("blockquote"," {0,3}>").replace("code","(?: {4}| {0,3} )[^\\n]").replace("fences"," {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n").replace("list"," {0,3}(?:[*+-]|1[.)])[ \\t]").replace("html",")|<(?:script|pre|style|textarea|!--)").replace("tag",H).getRegex(),ve={...X,lheading:Ie,table:ie,paragraph:d(U).replace("hr",D).replace("heading"," {0,3}#{1,6}(?:\\s|$)").replace("|lheading","").replace("table",ie).replace("blockquote"," {0,3}>").replace("fences"," {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n").replace("list"," {0,3}(?:[*+-]|1[.)])[ \\t]+[^ \\t\\n]").replace("html",")|<(?:script|pre|style|textarea|!--)").replace("tag",H).getRegex()},He={...X,html:d(`^ *(?:comment *(?:\\n|\\s*$)|<(tag)[\\s\\S]+? *(?:\\n{2,}|\\s*$)|\\s]*)*?/?> *(?:\\n{2,}|\\s*$))`).replace("comment",W).replace(/tag/g,"(?!(?:a|em|strong|small|s|cite|q|dfn|abbr|data|time|code|var|samp|kbd|sub|sup|i|b|u|mark|ruby|rt|rp|bdi|bdo|span|br|wbr|ins|del|img)\\b)\\w+(?!:|[^\\w\\s@]*@)\\b").getRegex(),def:/^ *\[([^\]]+)\]: *]+)>?(?: +(["(][^\n]+[")]))? *(?:\n+|$)/,heading:/^(#{1,6})(.*)(?:\n+|$)/,fences:z,lheading:/^(.+?)\n {0,3}(=+|-+) *(?:\n+|$)/,paragraph:d(U).replace("hr",D).replace("heading",` *#{1,6} *[^ +]`).replace("lheading",le).replace("|table","").replace("blockquote"," {0,3}>").replace("|fences","").replace("|list","").replace("|html","").replace("|tag","").getRegex()},Ze=/^\\([!"#$%&'()*+,\-./:;<=>?@\[\]\\^_`{|}~])/,Ge=/^(`+)([^`]|[^`][\s\S]*?[^`])\1(?!`)/,pe=/^( {2,}|\\)\n(?!\s*$)/,Ne=/^(`+|[^`])(?:(?= {2,}\n)|[\s\S]*?(?:(?=[\\`+)[^`]+\k(?!`))*?\]\((?:\\[\s\S]|[^\\\(\)]|\((?:\\[\s\S]|[^\\\(\)])*\))*\)/).replace("precode-",Le?"(?`+)[^`]+\k(?!`)/).replace("html",/<(?! )[^<>]*?>/).getRegex(),he=/^(?:\*+(?:((?!\*)punct)|([^\s*]))?)|^_+(?:((?!_)punct)|([^\s_]))?/,Ke=d(he,"u").replace(/punct/g,I).getRegex(),We=d(he,"u").replace(/punct/g,ce).getRegex(),ke="^[^_*]*?__[^_*]*?\\*[^_*]*?(?=__)|[^*]+(?=[^*])|(?!\\*)punct(\\*+)(?=[\\s]|$)|notPunctSpace(\\*+)(?!\\*)(?=punctSpace|$)|(?!\\*)punctSpace(\\*+)(?=notPunctSpace)|[\\s](\\*+)(?!\\*)(?=punct)|(?!\\*)punct(\\*+)(?!\\*)(?=punct)|notPunctSpace(\\*+)(?=notPunctSpace)",Xe=d(ke,"gu").replace(/notPunctSpace/g,J).replace(/punctSpace/g,Z).replace(/punct/g,I).getRegex(),Je=d(ke,"gu").replace(/notPunctSpace/g,Fe).replace(/punctSpace/g,je).replace(/punct/g,ce).getRegex(),Ve=d("^[^_*]*?\\*\\*[^_*]*?_[^_*]*?(?=\\*\\*)|[^_]+(?=[^_])|(?!_)punct(_+)(?=[\\s]|$)|notPunctSpace(_+)(?!_)(?=punctSpace|$)|(?!_)punctSpace(_+)(?=notPunctSpace)|[\\s](_+)(?!_)(?=punct)|(?!_)punct(_+)(?!_)(?=punct)","gu").replace(/notPunctSpace/g,J).replace(/punctSpace/g,Z).replace(/punct/g,I).getRegex(),Ye=d(/^~~?(?:((?!~)punct)|[^\s~])/,"u").replace(/punct/g,I).getRegex(),et="^[^~]+(?=[^~])|(?!~)punct(~~?)(?=[\\s]|$)|notPunctSpace(~~?)(?!~)(?=punctSpace|$)|(?!~)punctSpace(~~?)(?=notPunctSpace)|[\\s](~~?)(?!~)(?=punct)|(?!~)punct(~~?)(?!~)(?=punct)|notPunctSpace(~~?)(?=notPunctSpace)",tt=d(et,"gu").replace(/notPunctSpace/g,J).replace(/punctSpace/g,Z).replace(/punct/g,I).getRegex(),nt=d(/\\(punct)/,"gu").replace(/punct/g,I).getRegex(),rt=d(/^<(scheme:[^\s\x00-\x1f<>]*|email)>/).replace("scheme",/[a-zA-Z][a-zA-Z0-9+.-]{1,31}/).replace("email",/[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+(@)[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+(?![-_])/).getRegex(),st=d(W).replace("(?:-->|$)","-->").getRegex(),it=d("^comment|^|^<[a-zA-Z][\\w-]*(?:attribute)*?\\s*/?>|^<\\?[\\s\\S]*?\\?>|^|^").replace("comment",st).replace("attribute",/\s+[a-zA-Z:_][\w.:-]*(?:\s*=\s*"[^"]*"|\s*=\s*'[^']*'|\s*=\s*[^\s"'=<>`]+)?/).getRegex(),v=/(?:\[(?:\\[\s\S]|[^\[\]\\])*\]|\\[\s\S]|`+(?!`)[^`]*?`+(?!`)|``+(?=\])|[^\[\]\\`])*?/,ot=d(/^!?\[(label)\]\(\s*(href)(?:(?:[ \t]+(?:\n[ \t]*)?|\n[ \t]*)(title))?\s*\)/).replace("label",v).replace("href",/<(?:\\.|[^\n<>\\])+>|[^ \t\n\x00-\x1f]*/).replace("title",/"(?:\\"?|[^"\\])*"|'(?:\\'?|[^'\\])*'|\((?:\\\)?|[^)\\])*\)/).getRegex(),de=d(/^!?\[(label)\]\[(ref)\]/).replace("label",v).replace("ref",K).getRegex(),ge=d(/^!?\[(ref)\](?:\[\])?/).replace("ref",K).getRegex(),at=d("reflink|nolink(?!\\()","g").replace("reflink",de).replace("nolink",ge).getRegex(),oe=/[hH][tT][tT][pP][sS]?|[fF][tT][pP]/,V={_backpedal:z,anyPunctuation:nt,autolink:rt,blockSkip:Ue,br:pe,code:Ge,del:z,delLDelim:z,delRDelim:z,emStrongLDelim:Ke,emStrongRDelimAst:Xe,emStrongRDelimUnd:Ve,escape:Ze,link:ot,nolink:ge,punctuation:Qe,reflink:de,reflinkSearch:at,tag:it,text:Ne,url:z},lt={...V,link:d(/^!?\[(label)\]\((.*?)\)/).replace("label",v).getRegex(),reflink:d(/^!?\[(label)\]\s*\[([^\]]*)\]/).replace("label",v).getRegex()},j={...V,emStrongRDelimAst:Je,emStrongLDelim:We,delLDelim:Ye,delRDelim:tt,url:d(/^((?:protocol):\/\/|www\.)(?:[a-zA-Z0-9\-]+\.?)+[^\s<]*|^email/).replace("protocol",oe).replace("email",/[A-Za-z0-9._+-]+(@)[a-zA-Z0-9-_]+(?:\.[a-zA-Z0-9-_]*[a-zA-Z0-9])+(?![-_])/).getRegex(),_backpedal:/(?:[^?!.,:;*_'"~()&]+|\([^)]*\)|&(?![a-zA-Z0-9]+;$)|[?!.,:;*_'"~)]+(?!$))+/,del:/^(~~?)(?=[^\s~])((?:\\[\s\S]|[^\\])*?(?:\\[\s\S]|[^\s~\\]))\1(?=[^~]|$)/,text:d(/^([`~]+|[^`~])(?:(?= {2,}\n)|(?=[a-zA-Z0-9.!#$%&'*+\/=?_`{\|}~-]+@)|[\s\S]*?(?:(?=[\\":">",'"':""","'":"'"},fe=l=>pt[l];function O(l,e){if(e){if(m.escapeTest.test(l))return l.replace(m.escapeReplace,fe)}else if(m.escapeTestNoEncode.test(l))return l.replace(m.escapeReplaceNoEncode,fe);return l}function Y(l){try{l=encodeURI(l).replace(m.percentDecode,"%")}catch{return null}return l}function ee(l,e){let t=l.replace(m.findPipe,(r,i,o)=>{let u=!1,a=i;for(;--a>=0&&o[a]==="\\";)u=!u;return u?"|":" |"}),n=t.split(m.splitPipe),s=0;if(n[0].trim()||n.shift(),n.length>0&&!n.at(-1)?.trim()&&n.pop(),e)if(n.length>e)n.splice(e);else for(;n.length=0&&m.blankLine.test(e[t]);)t--;return e.length-t<=2?l:e.slice(0,t+1).join(` +`)}function me(l,e){if(l.indexOf(e[1])===-1)return-1;let t=0;for(let n=0;n0?-2:-1}function xe(l,e=0){let t=e,n="";for(let s of l)if(s===" "){let r=4-t%4;n+=" ".repeat(r),t+=r}else n+=s,t++;return n}function be(l,e,t,n,s){let r=e.href,i=e.title||null,o=l[1].replace(s.other.outputLinkReplace,"$1");n.state.inLink=!0;let u={type:l[0].charAt(0)==="!"?"image":"link",raw:t,href:r,title:i,text:o,tokens:n.inlineTokens(o)};return n.state.inLink=!1,u}function ct(l,e,t){let n=l.match(t.other.indentCodeCompensation);if(n===null)return e;let s=n[1];return e.split(` +`).map(r=>{let i=r.match(t.other.beginningSpace);if(i===null)return r;let[o]=i;return o.length>=s.length?r.slice(s.length):r}).join(` +`)}var w=class{options;rules;lexer;constructor(e){this.options=e||T}space(e){let t=this.rules.block.newline.exec(e);if(t&&t[0].length>0)return{type:"space",raw:t[0]}}code(e){let t=this.rules.block.code.exec(e);if(t){let n=this.options.pedantic?t[0]:te(t[0]),s=n.replace(this.rules.other.codeRemoveIndent,"");return{type:"code",raw:n,codeBlockStyle:"indented",text:s}}}fences(e){let t=this.rules.block.fences.exec(e);if(t){let n=t[0],s=ct(n,t[3]||"",this.rules);return{type:"code",raw:n,lang:t[2]?t[2].trim().replace(this.rules.inline.anyPunctuation,"$1"):t[2],text:s}}}heading(e){let t=this.rules.block.heading.exec(e);if(t){let n=t[2].trim();if(this.rules.other.endingHash.test(n)){let s=L(n,"#");(this.options.pedantic||!s||this.rules.other.endingSpaceChar.test(s))&&(n=s.trim())}return{type:"heading",raw:L(t[0],` +`),depth:t[1].length,text:n,tokens:this.lexer.inline(n)}}}hr(e){let t=this.rules.block.hr.exec(e);if(t)return{type:"hr",raw:L(t[0],` +`)}}blockquote(e){let t=this.rules.block.blockquote.exec(e);if(t){let n=L(t[0],` +`).split(` +`),s="",r="",i=[];for(;n.length>0;){let o=!1,u=[],a;for(a=0;a1,r={type:"list",raw:"",ordered:s,start:s?+n.slice(0,-1):"",loose:!1,items:[]};n=s?`\\d{1,9}\\${n.slice(-1)}`:`\\${n}`,this.options.pedantic&&(n=s?n:"[*+-]");let i=this.rules.other.listItemRegex(n),o=!1;for(;e;){let a=!1,c="",p="";if(!(t=i.exec(e))||this.rules.block.hr.test(e))break;c=t[0],e=e.substring(c.length);let k=xe(t[2].split(` +`,1)[0],t[1].length),h=e.split(` +`,1)[0],R=!k.trim(),f=0;if(this.options.pedantic?(f=2,p=k.trimStart()):R?f=t[1].length+1:(f=k.search(this.rules.other.nonSpaceChar),f=f>4?1:f,p=k.slice(f),f+=t[1].length),R&&this.rules.other.blankLine.test(h)&&(c+=h+` +`,e=e.substring(h.length+1),a=!0),!a){let $=this.rules.other.nextBulletRegex(f),ne=this.rules.other.hrRegex(f),re=this.rules.other.fencesBeginRegex(f),se=this.rules.other.headingBeginRegex(f),Re=this.rules.other.htmlBeginRegex(f),Te=this.rules.other.blockquoteBeginRegex(f);for(;e;){let G=e.split(` +`,1)[0],B;if(h=G,this.options.pedantic?(h=h.replace(this.rules.other.listReplaceNesting," "),B=h):B=h.replace(this.rules.other.tabCharGlobal," "),re.test(h)||se.test(h)||Re.test(h)||Te.test(h)||$.test(h)||ne.test(h))break;if(B.search(this.rules.other.nonSpaceChar)>=f||!h.trim())p+=` +`+B.slice(f);else{if(R||k.replace(this.rules.other.tabCharGlobal," ").search(this.rules.other.nonSpaceChar)>=4||re.test(k)||se.test(k)||ne.test(k))break;p+=` +`+h}R=!h.trim(),c+=G+` +`,e=e.substring(G.length+1),k=B.slice(f)}}r.loose||(o?r.loose=!0:this.rules.other.doubleBlankLine.test(c)&&(o=!0)),r.items.push({type:"list_item",raw:c,task:!!this.options.gfm&&this.rules.other.listIsTask.test(p),loose:!1,text:p,tokens:[]}),r.raw+=c}let u=r.items.at(-1);if(u)u.raw=u.raw.trimEnd(),u.text=u.text.trimEnd();else return;r.raw=r.raw.trimEnd();for(let a of r.items){this.lexer.state.top=!1,a.tokens=this.lexer.blockTokens(a.text,[]);let c=a.tokens[0];if(a.task&&(c?.type==="text"||c?.type==="paragraph")){a.text=a.text.replace(this.rules.other.listReplaceTask,""),c.raw=c.raw.replace(this.rules.other.listReplaceTask,""),c.text=c.text.replace(this.rules.other.listReplaceTask,"");for(let k=this.lexer.inlineQueue.length-1;k>=0;k--)if(this.rules.other.listIsTask.test(this.lexer.inlineQueue[k].src)){this.lexer.inlineQueue[k].src=this.lexer.inlineQueue[k].src.replace(this.rules.other.listReplaceTask,"");break}let p=this.rules.other.listTaskCheckbox.exec(a.raw);if(p){let k={type:"checkbox",raw:p[0]+" ",checked:p[0]!=="[ ]"};a.checked=k.checked,r.loose?a.tokens[0]&&["paragraph","text"].includes(a.tokens[0].type)&&"tokens"in a.tokens[0]&&a.tokens[0].tokens?(a.tokens[0].raw=k.raw+a.tokens[0].raw,a.tokens[0].text=k.raw+a.tokens[0].text,a.tokens[0].tokens.unshift(k)):a.tokens.unshift({type:"paragraph",raw:k.raw,text:k.raw,tokens:[k]}):a.tokens.unshift(k)}}else a.task&&(a.task=!1);if(!r.loose){let p=a.tokens.filter(h=>h.type==="space"),k=p.length>0&&p.some(h=>this.rules.other.anyLine.test(h.raw));r.loose=k}}if(r.loose)for(let a of r.items){a.loose=!0;for(let c of a.tokens)c.type==="text"&&(c.type="paragraph")}return r}}html(e){let t=this.rules.block.html.exec(e);if(t){let n=te(t[0]);return{type:"html",block:!0,raw:n,pre:t[1]==="pre"||t[1]==="script"||t[1]==="style",text:n}}}def(e){let t=this.rules.block.def.exec(e);if(t){let n=t[1].toLowerCase().replace(this.rules.other.multipleSpaceGlobal," "),s=t[2]?t[2].replace(this.rules.other.hrefBrackets,"$1").replace(this.rules.inline.anyPunctuation,"$1"):"",r=t[3]?t[3].substring(1,t[3].length-1).replace(this.rules.inline.anyPunctuation,"$1"):t[3];return{type:"def",tag:n,raw:L(t[0],` +`),href:s,title:r}}}table(e){let t=this.rules.block.table.exec(e);if(!t||!this.rules.other.tableDelimiter.test(t[2]))return;let n=ee(t[1]),s=t[2].replace(this.rules.other.tableAlignChars,"").split("|"),r=t[3]?.trim()?t[3].replace(this.rules.other.tableRowBlankLine,"").split(` +`):[],i={type:"table",raw:L(t[0],` +`),header:[],align:[],rows:[]};if(n.length===s.length){for(let o of s)this.rules.other.tableAlignRight.test(o)?i.align.push("right"):this.rules.other.tableAlignCenter.test(o)?i.align.push("center"):this.rules.other.tableAlignLeft.test(o)?i.align.push("left"):i.align.push(null);for(let o=0;o({text:u,tokens:this.lexer.inline(u),header:!1,align:i.align[a]})));return i}}lheading(e){let t=this.rules.block.lheading.exec(e);if(t){let n=t[1].trim();return{type:"heading",raw:L(t[0],` +`),depth:t[2].charAt(0)==="="?1:2,text:n,tokens:this.lexer.inline(n)}}}paragraph(e){let t=this.rules.block.paragraph.exec(e);if(t){let n=t[1].charAt(t[1].length-1)===` +`?t[1].slice(0,-1):t[1];return{type:"paragraph",raw:t[0],text:n,tokens:this.lexer.inline(n)}}}text(e){let t=this.rules.block.text.exec(e);if(t)return{type:"text",raw:t[0],text:t[0],tokens:this.lexer.inline(t[0])}}escape(e){let t=this.rules.inline.escape.exec(e);if(t)return{type:"escape",raw:t[0],text:t[1]}}tag(e){let t=this.rules.inline.tag.exec(e);if(t)return!this.lexer.state.inLink&&this.rules.other.startATag.test(t[0])?this.lexer.state.inLink=!0:this.lexer.state.inLink&&this.rules.other.endATag.test(t[0])&&(this.lexer.state.inLink=!1),!this.lexer.state.inRawBlock&&this.rules.other.startPreScriptTag.test(t[0])?this.lexer.state.inRawBlock=!0:this.lexer.state.inRawBlock&&this.rules.other.endPreScriptTag.test(t[0])&&(this.lexer.state.inRawBlock=!1),{type:"html",raw:t[0],inLink:this.lexer.state.inLink,inRawBlock:this.lexer.state.inRawBlock,block:!1,text:t[0]}}link(e){let t=this.rules.inline.link.exec(e);if(t){let n=t[2].trim();if(!this.options.pedantic&&this.rules.other.startAngleBracket.test(n)){if(!this.rules.other.endAngleBracket.test(n))return;let i=L(n.slice(0,-1),"\\");if((n.length-i.length)%2===0)return}else{let i=me(t[2],"()");if(i===-2)return;if(i>-1){let u=(t[0].indexOf("!")===0?5:4)+t[1].length+i;t[2]=t[2].substring(0,i),t[0]=t[0].substring(0,u).trim(),t[3]=""}}let s=t[2],r="";if(this.options.pedantic){let i=this.rules.other.pedanticHrefTitle.exec(s);i&&(s=i[1],r=i[3])}else r=t[3]?t[3].slice(1,-1):"";return s=s.trim(),this.rules.other.startAngleBracket.test(s)&&(this.options.pedantic&&!this.rules.other.endAngleBracket.test(n)?s=s.slice(1):s=s.slice(1,-1)),be(t,{href:s&&s.replace(this.rules.inline.anyPunctuation,"$1"),title:r&&r.replace(this.rules.inline.anyPunctuation,"$1")},t[0],this.lexer,this.rules)}}reflink(e,t){let n;if((n=this.rules.inline.reflink.exec(e))||(n=this.rules.inline.nolink.exec(e))){let s=(n[2]||n[1]).replace(this.rules.other.multipleSpaceGlobal," "),r=t[s.toLowerCase()];if(!r){let i=n[0].charAt(0);return{type:"text",raw:i,text:i}}return be(n,r,n[0],this.lexer,this.rules)}}emStrong(e,t,n=""){let s=this.rules.inline.emStrongLDelim.exec(e);if(!s||!s[1]&&!s[2]&&!s[3]&&!s[4]||s[4]&&n.match(this.rules.other.unicodeAlphaNumeric))return;if(!(s[1]||s[3]||"")||!n||this.rules.inline.punctuation.exec(n)){let i=[...s[0]].length-1,o,u,a=i,c=0,p=s[0][0]==="*"?this.rules.inline.emStrongRDelimAst:this.rules.inline.emStrongRDelimUnd;for(p.lastIndex=0,t=t.slice(-1*e.length+i);(s=p.exec(t))!==null;){if(o=s[1]||s[2]||s[3]||s[4]||s[5]||s[6],!o)continue;if(u=[...o].length,s[3]||s[4]){a+=u;continue}else if((s[5]||s[6])&&i%3&&!((i+u)%3)){c+=u;continue}if(a-=u,a>0)continue;u=Math.min(u,u+a+c);let k=[...s[0]][0].length,h=e.slice(0,i+s.index+k+u);if(Math.min(i,u)%2){let f=h.slice(1,-1);return{type:"em",raw:h,text:f,tokens:this.lexer.inlineTokens(f)}}let R=h.slice(2,-2);return{type:"strong",raw:h,text:R,tokens:this.lexer.inlineTokens(R)}}}}codespan(e){let t=this.rules.inline.code.exec(e);if(t){let n=t[2].replace(this.rules.other.newLineCharGlobal," "),s=this.rules.other.nonSpaceChar.test(n),r=this.rules.other.startingSpaceChar.test(n)&&this.rules.other.endingSpaceChar.test(n);return s&&r&&(n=n.substring(1,n.length-1)),{type:"codespan",raw:t[0],text:n}}}br(e){let t=this.rules.inline.br.exec(e);if(t)return{type:"br",raw:t[0]}}del(e,t,n=""){let s=this.rules.inline.delLDelim.exec(e);if(!s)return;if(!(s[1]||"")||!n||this.rules.inline.punctuation.exec(n)){let i=[...s[0]].length-1,o,u,a=i,c=this.rules.inline.delRDelim;for(c.lastIndex=0,t=t.slice(-1*e.length+i);(s=c.exec(t))!==null;){if(o=s[1]||s[2]||s[3]||s[4]||s[5]||s[6],!o||(u=[...o].length,u!==i))continue;if(s[3]||s[4]){a+=u;continue}if(a-=u,a>0)continue;u=Math.min(u,u+a);let p=[...s[0]][0].length,k=e.slice(0,i+s.index+p+u),h=k.slice(i,-i);return{type:"del",raw:k,text:h,tokens:this.lexer.inlineTokens(h)}}}}autolink(e){let t=this.rules.inline.autolink.exec(e);if(t){let n,s;return t[2]==="@"?(n=t[1],s="mailto:"+n):(n=t[1],s=n),{type:"link",raw:t[0],text:n,href:s,tokens:[{type:"text",raw:n,text:n}]}}}url(e){let t;if(t=this.rules.inline.url.exec(e)){let n,s;if(t[2]==="@")n=t[0],s="mailto:"+n;else{let r;do r=t[0],t[0]=this.rules.inline._backpedal.exec(t[0])?.[0]??"";while(r!==t[0]);n=t[0],t[1]==="www."?s="http://"+t[0]:s=t[0]}return{type:"link",raw:t[0],text:n,href:s,tokens:[{type:"text",raw:n,text:n}]}}}inlineText(e){let t=this.rules.inline.text.exec(e);if(t){let n=this.lexer.state.inRawBlock;return{type:"text",raw:t[0],text:t[0],escaped:n}}}};var x=class l{tokens;options;state;inlineQueue;tokenizer;constructor(e){this.tokens=[],this.tokens.links=Object.create(null),this.options=e||T,this.options.tokenizer=this.options.tokenizer||new w,this.tokenizer=this.options.tokenizer,this.tokenizer.options=this.options,this.tokenizer.lexer=this,this.inlineQueue=[],this.state={inLink:!1,inRawBlock:!1,top:!0};let t={other:m,block:q.normal,inline:A.normal};this.options.pedantic?(t.block=q.pedantic,t.inline=A.pedantic):this.options.gfm&&(t.block=q.gfm,this.options.breaks?t.inline=A.breaks:t.inline=A.gfm),this.tokenizer.rules=t}static get rules(){return{block:q,inline:A}}static lex(e,t){return new l(t).lex(e)}static lexInline(e,t){return new l(t).inlineTokens(e)}lex(e){e=e.replace(m.carriageReturn,` +`),this.blockTokens(e,this.tokens);for(let t=0;t(r=o.call({lexer:this},e,t))?(e=e.substring(r.raw.length),t.push(r),!0):!1))continue;if(r=this.tokenizer.space(e)){e=e.substring(r.raw.length);let o=t.at(-1);r.raw.length===1&&o!==void 0?o.raw+=` +`:t.push(r);continue}if(r=this.tokenizer.code(e)){e=e.substring(r.raw.length);let o=t.at(-1);o?.type==="paragraph"||o?.type==="text"?(o.raw+=(o.raw.endsWith(` +`)?"":` +`)+r.raw,o.text+=` +`+r.text,this.inlineQueue.at(-1).src=o.text):t.push(r);continue}if(r=this.tokenizer.fences(e)){e=e.substring(r.raw.length),t.push(r);continue}if(r=this.tokenizer.heading(e)){e=e.substring(r.raw.length),t.push(r);continue}if(r=this.tokenizer.hr(e)){e=e.substring(r.raw.length),t.push(r);continue}if(r=this.tokenizer.blockquote(e)){e=e.substring(r.raw.length),t.push(r);continue}if(r=this.tokenizer.list(e)){e=e.substring(r.raw.length),t.push(r);continue}if(r=this.tokenizer.html(e)){e=e.substring(r.raw.length),t.push(r);continue}if(r=this.tokenizer.def(e)){e=e.substring(r.raw.length);let o=t.at(-1);o?.type==="paragraph"||o?.type==="text"?(o.raw+=(o.raw.endsWith(` +`)?"":` +`)+r.raw,o.text+=` +`+r.raw,this.inlineQueue.at(-1).src=o.text):this.tokens.links[r.tag]||(this.tokens.links[r.tag]={href:r.href,title:r.title},t.push(r));continue}if(r=this.tokenizer.table(e)){e=e.substring(r.raw.length),t.push(r);continue}if(r=this.tokenizer.lheading(e)){e=e.substring(r.raw.length),t.push(r);continue}let i=e;if(this.options.extensions?.startBlock){let o=1/0,u=e.slice(1),a;this.options.extensions.startBlock.forEach(c=>{a=c.call({lexer:this},u),typeof a=="number"&&a>=0&&(o=Math.min(o,a))}),o<1/0&&o>=0&&(i=e.substring(0,o+1))}if(this.state.top&&(r=this.tokenizer.paragraph(i))){let o=t.at(-1);n&&o?.type==="paragraph"?(o.raw+=(o.raw.endsWith(` +`)?"":` +`)+r.raw,o.text+=` +`+r.text,this.inlineQueue.pop(),this.inlineQueue.at(-1).src=o.text):t.push(r),n=i.length!==e.length,e=e.substring(r.raw.length);continue}if(r=this.tokenizer.text(e)){e=e.substring(r.raw.length);let o=t.at(-1);o?.type==="text"?(o.raw+=(o.raw.endsWith(` +`)?"":` +`)+r.raw,o.text+=` +`+r.text,this.inlineQueue.pop(),this.inlineQueue.at(-1).src=o.text):t.push(r);continue}if(e){this.infiniteLoopError(e.charCodeAt(0));break}}return this.state.top=!0,t}inline(e,t=[]){return this.inlineQueue.push({src:e,tokens:t}),t}inlineTokens(e,t=[]){this.tokenizer.lexer=this;let n=e,s=null;if(this.tokens.links){let a=Object.keys(this.tokens.links);if(a.length>0)for(;(s=this.tokenizer.rules.inline.reflinkSearch.exec(n))!==null;)a.includes(s[0].slice(s[0].lastIndexOf("[")+1,-1))&&(n=n.slice(0,s.index)+"["+"a".repeat(s[0].length-2)+"]"+n.slice(this.tokenizer.rules.inline.reflinkSearch.lastIndex))}for(;(s=this.tokenizer.rules.inline.anyPunctuation.exec(n))!==null;)n=n.slice(0,s.index)+"++"+n.slice(this.tokenizer.rules.inline.anyPunctuation.lastIndex);let r;for(;(s=this.tokenizer.rules.inline.blockSkip.exec(n))!==null;)r=s[2]?s[2].length:0,n=n.slice(0,s.index+r)+"["+"a".repeat(s[0].length-r-2)+"]"+n.slice(this.tokenizer.rules.inline.blockSkip.lastIndex);n=this.options.hooks?.emStrongMask?.call({lexer:this},n)??n;let i=!1,o="",u=1/0;for(;e;){if(e.length(a=p.call({lexer:this},e,t))?(e=e.substring(a.raw.length),t.push(a),!0):!1))continue;if(a=this.tokenizer.escape(e)){e=e.substring(a.raw.length),t.push(a);continue}if(a=this.tokenizer.tag(e)){e=e.substring(a.raw.length),t.push(a);continue}if(a=this.tokenizer.link(e)){e=e.substring(a.raw.length),t.push(a);continue}if(a=this.tokenizer.reflink(e,this.tokens.links)){e=e.substring(a.raw.length);let p=t.at(-1);a.type==="text"&&p?.type==="text"?(p.raw+=a.raw,p.text+=a.text):t.push(a);continue}if(a=this.tokenizer.emStrong(e,n,o)){e=e.substring(a.raw.length),t.push(a);continue}if(a=this.tokenizer.codespan(e)){e=e.substring(a.raw.length),t.push(a);continue}if(a=this.tokenizer.br(e)){e=e.substring(a.raw.length),t.push(a);continue}if(a=this.tokenizer.del(e,n,o)){e=e.substring(a.raw.length),t.push(a);continue}if(a=this.tokenizer.autolink(e)){e=e.substring(a.raw.length),t.push(a);continue}if(!this.state.inLink&&(a=this.tokenizer.url(e))){e=e.substring(a.raw.length),t.push(a);continue}let c=e;if(this.options.extensions?.startInline){let p=1/0,k=e.slice(1),h;this.options.extensions.startInline.forEach(R=>{h=R.call({lexer:this},k),typeof h=="number"&&h>=0&&(p=Math.min(p,h))}),p<1/0&&p>=0&&(c=e.substring(0,p+1))}if(a=this.tokenizer.inlineText(c)){e=e.substring(a.raw.length),a.raw.slice(-1)!=="_"&&(o=a.raw.slice(-1)),i=!0;let p=t.at(-1);p?.type==="text"?(p.raw+=a.raw,p.text+=a.text):t.push(a);continue}if(e){this.infiniteLoopError(e.charCodeAt(0));break}}return t}infiniteLoopError(e){let t="Infinite loop on byte: "+e;if(this.options.silent)console.error(t);else throw new Error(t)}};var y=class{options;parser;constructor(e){this.options=e||T}space(e){return""}code({text:e,lang:t,escaped:n}){let s=(t||"").match(m.notSpaceStart)?.[0],r=e.replace(m.endingNewline,"")+` +`;return s?'
    '+(n?r:O(r,!0))+`
    +`:"
    "+(n?r:O(r,!0))+`
    +`}blockquote({tokens:e}){return`
    +${this.parser.parse(e)}
    +`}html({text:e}){return e}def(e){return""}heading({tokens:e,depth:t}){return`${this.parser.parseInline(e)} +`}hr(e){return`
    +`}list(e){let t=e.ordered,n=e.start,s="";for(let o=0;o +`+s+" +`}listitem(e){return`
  • ${this.parser.parse(e.tokens)}
  • +`}checkbox({checked:e}){return" '}paragraph({tokens:e}){return`

    ${this.parser.parseInline(e)}

    +`}table(e){let t="",n="";for(let r=0;r${s}`),` + +`+t+` +`+s+`
    +`}tablerow({text:e}){return` +${e} +`}tablecell(e){let t=this.parser.parseInline(e.tokens),n=e.header?"th":"td";return(e.align?`<${n} align="${e.align}">`:`<${n}>`)+t+` +`}strong({tokens:e}){return`${this.parser.parseInline(e)}`}em({tokens:e}){return`${this.parser.parseInline(e)}`}codespan({text:e}){return`${O(e,!0)}`}br(e){return"
    "}del({tokens:e}){return`${this.parser.parseInline(e)}`}link({href:e,title:t,tokens:n}){let s=this.parser.parseInline(n),r=Y(e);if(r===null)return s;e=r;let i='
    ",i}image({href:e,title:t,text:n,tokens:s}){s&&(n=this.parser.parseInline(s,this.parser.textRenderer));let r=Y(e);if(r===null)return O(n);e=r;let i=`${O(n)}{let o=r[i].flat(1/0);n=n.concat(this.walkTokens(o,t))}):r.tokens&&(n=n.concat(this.walkTokens(r.tokens,t)))}}return n}use(...e){let t=this.defaults.extensions||{renderers:{},childTokens:{}};return e.forEach(n=>{let s={...n};if(s.async=this.defaults.async||s.async||!1,n.extensions&&(n.extensions.forEach(r=>{if(!r.name)throw new Error("extension name required");if("renderer"in r){let i=t.renderers[r.name];i?t.renderers[r.name]=function(...o){let u=r.renderer.apply(this,o);return u===!1&&(u=i.apply(this,o)),u}:t.renderers[r.name]=r.renderer}if("tokenizer"in r){if(!r.level||r.level!=="block"&&r.level!=="inline")throw new Error("extension level must be 'block' or 'inline'");let i=t[r.level];i?i.unshift(r.tokenizer):t[r.level]=[r.tokenizer],r.start&&(r.level==="block"?t.startBlock?t.startBlock.push(r.start):t.startBlock=[r.start]:r.level==="inline"&&(t.startInline?t.startInline.push(r.start):t.startInline=[r.start]))}"childTokens"in r&&r.childTokens&&(t.childTokens[r.name]=r.childTokens)}),s.extensions=t),n.renderer){let r=this.defaults.renderer||new y(this.defaults);for(let i in n.renderer){if(!(i in r))throw new Error(`renderer '${i}' does not exist`);if(["options","parser"].includes(i))continue;let o=i,u=n.renderer[o],a=r[o];r[o]=(...c)=>{let p=u.apply(r,c);return p===!1&&(p=a.apply(r,c)),p||""}}s.renderer=r}if(n.tokenizer){let r=this.defaults.tokenizer||new w(this.defaults);for(let i in n.tokenizer){if(!(i in r))throw new Error(`tokenizer '${i}' does not exist`);if(["options","rules","lexer"].includes(i))continue;let o=i,u=n.tokenizer[o],a=r[o];r[o]=(...c)=>{let p=u.apply(r,c);return p===!1&&(p=a.apply(r,c)),p}}s.tokenizer=r}if(n.hooks){let r=this.defaults.hooks||new P;for(let i in n.hooks){if(!(i in r))throw new Error(`hook '${i}' does not exist`);if(["options","block"].includes(i))continue;let o=i,u=n.hooks[o],a=r[o];P.passThroughHooks.has(i)?r[o]=c=>{if(this.defaults.async&&P.passThroughHooksRespectAsync.has(i))return(async()=>{let k=await u.call(r,c);return a.call(r,k)})();let p=u.call(r,c);return a.call(r,p)}:r[o]=(...c)=>{if(this.defaults.async)return(async()=>{let k=await u.apply(r,c);return k===!1&&(k=await a.apply(r,c)),k})();let p=u.apply(r,c);return p===!1&&(p=a.apply(r,c)),p}}s.hooks=r}if(n.walkTokens){let r=this.defaults.walkTokens,i=n.walkTokens;s.walkTokens=function(o){let u=[];return u.push(i.call(this,o)),r&&(u=u.concat(r.call(this,o))),u}}this.defaults={...this.defaults,...s}}),this}setOptions(e){return this.defaults={...this.defaults,...e},this}lexer(e,t){return x.lex(e,t??this.defaults)}parser(e,t){return b.parse(e,t??this.defaults)}parseMarkdown(e){return(n,s)=>{let r={...s},i={...this.defaults,...r},o=this.onError(!!i.silent,!!i.async);if(this.defaults.async===!0&&r.async===!1)return o(new Error("marked(): The async option was set to true by an extension. Remove async: false from the parse options object to return a Promise."));if(typeof n>"u"||n===null)return o(new Error("marked(): input parameter is undefined or null"));if(typeof n!="string")return o(new Error("marked(): input parameter is of type "+Object.prototype.toString.call(n)+", string expected"));if(i.hooks&&(i.hooks.options=i,i.hooks.block=e),i.async)return(async()=>{let u=i.hooks?await i.hooks.preprocess(n):n,c=await(i.hooks?await i.hooks.provideLexer(e):e?x.lex:x.lexInline)(u,i),p=i.hooks?await i.hooks.processAllTokens(c):c;i.walkTokens&&await Promise.all(this.walkTokens(p,i.walkTokens));let h=await(i.hooks?await i.hooks.provideParser(e):e?b.parse:b.parseInline)(p,i);return i.hooks?await i.hooks.postprocess(h):h})().catch(o);try{i.hooks&&(n=i.hooks.preprocess(n));let a=(i.hooks?i.hooks.provideLexer(e):e?x.lex:x.lexInline)(n,i);i.hooks&&(a=i.hooks.processAllTokens(a)),i.walkTokens&&this.walkTokens(a,i.walkTokens);let p=(i.hooks?i.hooks.provideParser(e):e?b.parse:b.parseInline)(a,i);return i.hooks&&(p=i.hooks.postprocess(p)),p}catch(u){return o(u)}}}onError(e,t){return n=>{if(n.message+=` +Please report this to https://github.com/markedjs/marked.`,e){let s="

    An error occurred:

    "+O(n.message+"",!0)+"
    ";return t?Promise.resolve(s):s}if(t)return Promise.reject(n);throw n}}};var M=new C;function g(l,e){return M.parse(l,e)}g.options=g.setOptions=function(l){return M.setOptions(l),g.defaults=M.defaults,Q(g.defaults),g};g.getDefaults=_;g.defaults=T;g.use=function(...l){return M.use(...l),g.defaults=M.defaults,Q(g.defaults),g};g.walkTokens=function(l,e){return M.walkTokens(l,e)};g.parseInline=M.parseInline;g.Parser=b;g.parser=b.parse;g.Renderer=y;g.TextRenderer=S;g.Lexer=x;g.lexer=x.lex;g.Tokenizer=w;g.Hooks=P;g.parse=g;var ht=g.options,kt=g.setOptions,dt=g.use,gt=g.walkTokens,ft=g.parseInline,mt=g,xt=b.parse,bt=x.lex; + +if(__exports != exports)module.exports = exports;return module.exports})); +//# sourceMappingURL=marked.umd.js.map diff --git a/assets/web_export/purify.min.js b/assets/web_export/purify.min.js new file mode 100644 index 0000000..5f74640 --- /dev/null +++ b/assets/web_export/purify.min.js @@ -0,0 +1,3 @@ +/*! @license DOMPurify 3.4.9 | (c) Cure53 and other contributors | Released under the Apache license 2.0 and Mozilla Public License 2.0 | github.com/cure53/DOMPurify/blob/3.4.9/LICENSE */ +!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?module.exports=t():"function"==typeof define&&define.amd?define(t):(e="undefined"!=typeof globalThis?globalThis:e||self).DOMPurify=t()}(this,function(){"use strict";function e(e,t){(null==t||t>e.length)&&(t=e.length);for(var n=0,o=Array(t);n2?n-2:0),r=2;r1?t-1:0),o=1;o1?n-1:0),r=1;r2&&void 0!==arguments[2]?arguments[2]:b;if(o&&o(e,null),!T(t))return e;let i=t.length;for(;i--;){let o=t[i];if("string"==typeof o){const e=n(o);e!==o&&(r(t)||(t[i]=e),o=e)}e[o]=!0}return e}function P(e){for(let t=0;t/g),J=c(/\${[\w\W]*/g),Q=c(/^data-[\-\w.\u00B7-\uFFFF]+$/),ee=c(/^aria-[\-\w]+$/),te=c(/^(?:(?:(?:f|ht)tps?|mailto|tel|callto|sms|cid|xmpp|matrix):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i),ne=c(/^(?:\w+script|data):/i),oe=c(/[\u0000-\u0020\u00A0\u1680\u180E\u2000-\u2029\u205F\u3000]/g),re=c(/^html$/i),ie=c(/^[a-z][.\w]*(-[.\w]+)+$/i),ae=1,le=3,ce=7,se=8,ue=9,fe=11,pe=function(){return"undefined"==typeof window?null:window};var me=function e(){let t=arguments.length>0&&void 0!==arguments[0]?arguments[0]:pe();const o=t=>e(t);if(o.version="3.4.9",o.removed=[],!t||!t.document||t.document.nodeType!==ue||!t.Element)return o.isSupported=!1,o;let r=t.document;const i=r,a=i.currentScript;t.DocumentFragment;const c=t.HTMLTemplateElement,u=t.Node,f=t.Element,p=t.NodeFilter,k=t.NamedNodeMap;void 0===k&&(t.NamedNodeMap||t.MozNamedAttrMap),t.HTMLFormElement;const L=t.DOMParser,P=t.trustedTypes,me=f.prototype,de=U(me,"cloneNode"),he=U(me,"remove"),ge=U(me,"nextSibling"),ye=U(me,"childNodes"),Te=U(me,"parentNode"),be=U(me,"shadowRoot"),Ae=U(me,"attributes"),Se=u&&u.prototype?U(u.prototype,"nodeType"):null,Ee=u&&u.prototype?U(u.prototype,"nodeName"):null;if("function"==typeof c){const e=r.createElement("template");e.content&&e.content.ownerDocument&&(r=e.content.ownerDocument)}let _e,Ne,Oe="",we=!1,De=0;const Re=function(){if(De>0)throw x('A configured TRUSTED_TYPES_POLICY callback (createHTML or createScriptURL) must not call DOMPurify.sanitize, as that causes infinite recursion. Do not pass a policy whose callbacks wrap DOMPurify as TRUSTED_TYPES_POLICY; see the "DOMPurify and Trusted Types" section of the README.')},ve=function(e){Re(),De++;try{return _e.createHTML(e)}finally{De--}},Ie=function(e){Re(),De++;try{return _e.createScriptURL(e)}finally{De--}},Ce=function(){return we||(Ne=function(e,t){if("object"!=typeof e||"function"!=typeof e.createPolicy)return null;let n=null;const o="data-tt-policy-suffix";t&&t.hasAttribute(o)&&(n=t.getAttribute(o));const r="dompurify"+(n?"#"+n:"");try{return e.createPolicy(r,{createHTML:e=>e,createScriptURL:e=>e})}catch(e){return console.warn("TrustedTypes policy "+r+" could not be created."),null}}(P,a),we=!0),Ne},xe=r,ke=xe.implementation,Le=xe.createNodeIterator,Me=xe.createDocumentFragment,Pe=xe.getElementsByTagName,ze=i.importNode;let Ue={afterSanitizeAttributes:[],afterSanitizeElements:[],afterSanitizeShadowDOM:[],beforeSanitizeAttributes:[],beforeSanitizeElements:[],beforeSanitizeShadowDOM:[],uponSanitizeAttribute:[],uponSanitizeElement:[],uponSanitizeShadowNode:[]};o.isSupported="function"==typeof n&&"function"==typeof Te&&ke&&void 0!==ke.createHTMLDocument;const Fe=V,He=Z,Be=J,je=Q,Ge=ee,We=ne,Ye=oe,qe=ie;let Xe=te,$e=null;const Ke=M({},[...F,...H,...B,...G,...Y]);let Ve=null;const Ze=M({},[...q,...X,...$,...K]);let Je=Object.seal(s(null,{tagNameCheck:{writable:!0,configurable:!1,enumerable:!0,value:null},attributeNameCheck:{writable:!0,configurable:!1,enumerable:!0,value:null},allowCustomizedBuiltInElements:{writable:!0,configurable:!1,enumerable:!0,value:!1}})),Qe=null,et=null;const tt=Object.seal(s(null,{tagCheck:{writable:!0,configurable:!1,enumerable:!0,value:null},attributeCheck:{writable:!0,configurable:!1,enumerable:!0,value:null}}));let nt=!0,ot=!0,rt=!1,it=!0,at=!1,lt=!0,ct=!1,st=!1,ut=!1,ft=!1,pt=!1,mt=!1,dt=!0,ht=!1;const gt="user-content-";let yt=!0,Tt=!1,bt={},At=null;const St=M({},["annotation-xml","audio","colgroup","desc","foreignobject","head","iframe","math","mi","mn","mo","ms","mtext","noembed","noframes","noscript","plaintext","script","selectedcontent","style","svg","template","thead","title","video","xmp"]);let Et=null;const _t=M({},["audio","video","img","source","image","track"]);let Nt=null;const Ot=M({},["alt","class","for","id","label","name","pattern","placeholder","role","summary","title","value","style","xmlns"]),wt="http://www.w3.org/1998/Math/MathML",Dt="http://www.w3.org/2000/svg",Rt="http://www.w3.org/1999/xhtml";let vt=Rt,It=!1,Ct=null;const xt=M({},[wt,Dt,Rt],A);let kt=M({},["mi","mo","mn","ms","mtext"]),Lt=M({},["annotation-xml"]);const Mt=M({},["title","style","font","a","script"]);let Pt=null;const zt=["application/xhtml+xml","text/html"];let Ut=null,Ft=null;const Ht=r.createElement("form"),Bt=function(e){return e instanceof RegExp||e instanceof Function},jt=function(){let e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{};if(Ft&&Ft===e)return;e&&"object"==typeof e||(e={}),e=z(e),Pt=-1===zt.indexOf(e.PARSER_MEDIA_TYPE)?"text/html":e.PARSER_MEDIA_TYPE,Ut="application/xhtml+xml"===Pt?A:b,$e=v(e,"ALLOWED_TAGS")&&T(e.ALLOWED_TAGS)?M({},e.ALLOWED_TAGS,Ut):Ke,Ve=v(e,"ALLOWED_ATTR")&&T(e.ALLOWED_ATTR)?M({},e.ALLOWED_ATTR,Ut):Ze,Ct=v(e,"ALLOWED_NAMESPACES")&&T(e.ALLOWED_NAMESPACES)?M({},e.ALLOWED_NAMESPACES,A):xt,Nt=v(e,"ADD_URI_SAFE_ATTR")&&T(e.ADD_URI_SAFE_ATTR)?M(z(Ot),e.ADD_URI_SAFE_ATTR,Ut):Ot,Et=v(e,"ADD_DATA_URI_TAGS")&&T(e.ADD_DATA_URI_TAGS)?M(z(_t),e.ADD_DATA_URI_TAGS,Ut):_t,At=v(e,"FORBID_CONTENTS")&&T(e.FORBID_CONTENTS)?M({},e.FORBID_CONTENTS,Ut):St,Qe=v(e,"FORBID_TAGS")&&T(e.FORBID_TAGS)?M({},e.FORBID_TAGS,Ut):z({}),et=v(e,"FORBID_ATTR")&&T(e.FORBID_ATTR)?M({},e.FORBID_ATTR,Ut):z({}),bt=!!v(e,"USE_PROFILES")&&(e.USE_PROFILES&&"object"==typeof e.USE_PROFILES?z(e.USE_PROFILES):e.USE_PROFILES),nt=!1!==e.ALLOW_ARIA_ATTR,ot=!1!==e.ALLOW_DATA_ATTR,rt=e.ALLOW_UNKNOWN_PROTOCOLS||!1,it=!1!==e.ALLOW_SELF_CLOSE_IN_ATTR,at=e.SAFE_FOR_TEMPLATES||!1,lt=!1!==e.SAFE_FOR_XML,ct=e.WHOLE_DOCUMENT||!1,ft=e.RETURN_DOM||!1,pt=e.RETURN_DOM_FRAGMENT||!1,mt=e.RETURN_TRUSTED_TYPE||!1,ut=e.FORCE_BODY||!1,dt=!1!==e.SANITIZE_DOM,ht=e.SANITIZE_NAMED_PROPS||!1,yt=!1!==e.KEEP_CONTENT,Tt=e.IN_PLACE||!1,Xe=function(e){try{return C(e,""),!0}catch(e){return!1}}(e.ALLOWED_URI_REGEXP)?e.ALLOWED_URI_REGEXP:te,vt="string"==typeof e.NAMESPACE?e.NAMESPACE:Rt,kt=v(e,"MATHML_TEXT_INTEGRATION_POINTS")&&e.MATHML_TEXT_INTEGRATION_POINTS&&"object"==typeof e.MATHML_TEXT_INTEGRATION_POINTS?z(e.MATHML_TEXT_INTEGRATION_POINTS):M({},["mi","mo","mn","ms","mtext"]),Lt=v(e,"HTML_INTEGRATION_POINTS")&&e.HTML_INTEGRATION_POINTS&&"object"==typeof e.HTML_INTEGRATION_POINTS?z(e.HTML_INTEGRATION_POINTS):M({},["annotation-xml"]);const t=v(e,"CUSTOM_ELEMENT_HANDLING")&&e.CUSTOM_ELEMENT_HANDLING&&"object"==typeof e.CUSTOM_ELEMENT_HANDLING?z(e.CUSTOM_ELEMENT_HANDLING):s(null);if(Je=s(null),v(t,"tagNameCheck")&&Bt(t.tagNameCheck)&&(Je.tagNameCheck=t.tagNameCheck),v(t,"attributeNameCheck")&&Bt(t.attributeNameCheck)&&(Je.attributeNameCheck=t.attributeNameCheck),v(t,"allowCustomizedBuiltInElements")&&"boolean"==typeof t.allowCustomizedBuiltInElements&&(Je.allowCustomizedBuiltInElements=t.allowCustomizedBuiltInElements),at&&(ot=!1),pt&&(ft=!0),bt&&($e=M({},Y),Ve=s(null),!0===bt.html&&(M($e,F),M(Ve,q)),!0===bt.svg&&(M($e,H),M(Ve,X),M(Ve,K)),!0===bt.svgFilters&&(M($e,B),M(Ve,X),M(Ve,K)),!0===bt.mathMl&&(M($e,G),M(Ve,$),M(Ve,K))),tt.tagCheck=null,tt.attributeCheck=null,v(e,"ADD_TAGS")&&("function"==typeof e.ADD_TAGS?tt.tagCheck=e.ADD_TAGS:T(e.ADD_TAGS)&&($e===Ke&&($e=z($e)),M($e,e.ADD_TAGS,Ut))),v(e,"ADD_ATTR")&&("function"==typeof e.ADD_ATTR?tt.attributeCheck=e.ADD_ATTR:T(e.ADD_ATTR)&&(Ve===Ze&&(Ve=z(Ve)),M(Ve,e.ADD_ATTR,Ut))),v(e,"ADD_URI_SAFE_ATTR")&&T(e.ADD_URI_SAFE_ATTR)&&M(Nt,e.ADD_URI_SAFE_ATTR,Ut),v(e,"FORBID_CONTENTS")&&T(e.FORBID_CONTENTS)&&(At===St&&(At=z(At)),M(At,e.FORBID_CONTENTS,Ut)),v(e,"ADD_FORBID_CONTENTS")&&T(e.ADD_FORBID_CONTENTS)&&(At===St&&(At=z(At)),M(At,e.ADD_FORBID_CONTENTS,Ut)),yt&&($e["#text"]=!0),ct&&M($e,["html","head","body"]),$e.table&&(M($e,["tbody"]),delete Qe.tbody),e.TRUSTED_TYPES_POLICY){if("function"!=typeof e.TRUSTED_TYPES_POLICY.createHTML)throw x('TRUSTED_TYPES_POLICY configuration option must provide a "createHTML" hook.');if("function"!=typeof e.TRUSTED_TYPES_POLICY.createScriptURL)throw x('TRUSTED_TYPES_POLICY configuration option must provide a "createScriptURL" hook.');const t=_e;_e=e.TRUSTED_TYPES_POLICY;try{Oe=ve("")}catch(e){throw _e=t,e}}else null===e.TRUSTED_TYPES_POLICY?(_e=void 0,Oe=""):(void 0===_e&&(_e=Ce()),_e&&"string"==typeof Oe&&(Oe=ve("")));(Ue.uponSanitizeElement.length>0||Ue.uponSanitizeAttribute.length>0)&&$e===Ke&&($e=z($e)),Ue.uponSanitizeAttribute.length>0&&Ve===Ze&&(Ve=z(Ve)),l&&l(e),Ft=e},Gt=M({},[...H,...B,...j]),Wt=M({},[...G,...W]),Yt=function(e){g(o.removed,{element:e});try{Te(e).removeChild(e)}catch(t){if(he(e),!Te(e))throw x("a node selected for removal could not be detached from its tree and cannot be safely returned; refusing to sanitize in place")}},qt=function(e){const t=ye?ye(e):e.childNodes;if(t){const e=[];m(t,t=>{g(e,t)}),m(e,e=>{try{he(e)}catch(e){}})}const n=Ae?Ae(e):null;if(n)for(let t=n.length-1;t>=0;--t){const o=n[t],r=o&&o.name;if("string"==typeof r)try{e.removeAttribute(r)}catch(e){}}},Xt=function(e,t){try{g(o.removed,{attribute:t.getAttributeNode(e),from:t})}catch(e){g(o.removed,{attribute:null,from:t})}if(t.removeAttribute(e),"is"===e)if(ft||pt)try{Yt(t)}catch(e){}else try{t.setAttribute(e,"")}catch(e){}},$t=function(e){const t=Ae?Ae(e):e.attributes;if(t)for(let n=t.length-1;n>=0;--n){const o=t[n],r=o&&o.name;if("string"==typeof r&&!Ve[Ut(r)])try{e.removeAttribute(r)}catch(e){}}},Kt=function(e){let t=null,n=null;if(ut)e=""+e;else{const t=S(e,/^[\r\n\t ]+/);n=t&&t[0]}"application/xhtml+xml"===Pt&&vt===Rt&&(e=''+e+"");const o=_e?ve(e):e;if(vt===Rt)try{t=(new L).parseFromString(o,Pt)}catch(e){}if(!t||!t.documentElement){t=ke.createDocument(vt,"template",null);try{t.documentElement.innerHTML=It?Oe:o}catch(e){}}const i=t.body||t.documentElement;return e&&n&&i.insertBefore(r.createTextNode(n),i.childNodes[0]||null),vt===Rt?Pe.call(t,ct?"html":"body")[0]:ct?t.documentElement:i},Vt=function(e){return Le.call(e.ownerDocument||e,e,p.SHOW_ELEMENT|p.SHOW_COMMENT|p.SHOW_TEXT|p.SHOW_PROCESSING_INSTRUCTION|p.SHOW_CDATA_SECTION,null)},Zt=function(e){var t,n;e.normalize();const o=Le.call(e.ownerDocument||e,e,p.SHOW_TEXT|p.SHOW_COMMENT|p.SHOW_CDATA_SECTION|p.SHOW_PROCESSING_INSTRUCTION,null);let r=o.nextNode();for(;r;){let e=r.data;m([Fe,He,Be],t=>{e=E(e,t," ")}),r.data=e,r=o.nextNode()}const i=null!==(t=null===(n=e.querySelectorAll)||void 0===n?void 0:n.call(e,"template"))&&void 0!==t?t:[];m(Array.from(i),e=>{Qt(e.content)&&Zt(e.content)})},Jt=function(e){const t=Ee?Ee(e):null;return"string"==typeof t&&("form"===Ut(t)&&("string"!=typeof e.nodeName||"string"!=typeof e.textContent||"function"!=typeof e.removeChild||e.attributes!==Ae(e)||"function"!=typeof e.removeAttribute||"function"!=typeof e.setAttribute||"string"!=typeof e.namespaceURI||"function"!=typeof e.insertBefore||"function"!=typeof e.hasChildNodes||e.nodeType!==Se(e)||e.childNodes!==ye(e)))},Qt=function(e){if(!Se||"object"!=typeof e||null===e)return!1;try{return Se(e)===fe}catch(e){return!1}},en=function(e){if(!Se||"object"!=typeof e||null===e)return!1;try{return"number"==typeof Se(e)}catch(e){return!1}};function tn(e,t,n){m(e,e=>{e.call(o,t,n,Ft)})}const nn=function(e){let t=null;if(tn(Ue.beforeSanitizeElements,e,null),Jt(e))return Yt(e),!0;const n=Ut(Ee?Ee(e):e.nodeName);if(tn(Ue.uponSanitizeElement,e,{tagName:n,allowedTags:$e}),lt&&e.hasChildNodes()&&!en(e.firstElementChild)&&C(/<[/\w!]/g,e.innerHTML)&&C(/<[/\w!]/g,e.textContent))return Yt(e),!0;if(lt&&e.namespaceURI===Rt&&"style"===n&&en(e.firstElementChild))return Yt(e),!0;if(e.nodeType===ce)return Yt(e),!0;if(lt&&e.nodeType===se&&C(/<[/\w]/g,e.data))return Yt(e),!0;if(Qe[n]||!(tt.tagCheck instanceof Function&&tt.tagCheck(n))&&!$e[n]){if(!Qe[n]&&an(n)){if(Je.tagNameCheck instanceof RegExp&&C(Je.tagNameCheck,n))return!1;if(Je.tagNameCheck instanceof Function&&Je.tagNameCheck(n))return!1}if(yt&&!At[n]){const t=Te(e),n=ye(e);if(n&&t){for(let o=n.length-1;o>=0;--o){const r=Tt?n[o]:de(n[o],!0);t.insertBefore(r,ge(e))}}}return Yt(e),!0}return((Se?Se(e):e.nodeType)!==ae||function(e){let t=Te(e);t&&t.tagName||(t={namespaceURI:vt,tagName:"template"});const n=b(e.tagName),o=b(t.tagName);return!!Ct[e.namespaceURI]&&(e.namespaceURI===Dt?t.namespaceURI===Rt?"svg"===n:t.namespaceURI===wt?"svg"===n&&("annotation-xml"===o||kt[o]):Boolean(Gt[n]):e.namespaceURI===wt?t.namespaceURI===Rt?"math"===n:t.namespaceURI===Dt?"math"===n&&Lt[o]:Boolean(Wt[n]):e.namespaceURI===Rt?!(t.namespaceURI===Dt&&!Lt[o])&&!(t.namespaceURI===wt&&!kt[o])&&!Wt[n]&&(Mt[n]||!Gt[n]):!("application/xhtml+xml"!==Pt||!Ct[e.namespaceURI]))}(e))&&("noscript"!==n&&"noembed"!==n&&"noframes"!==n||!C(/<\/no(script|embed|frames)/i,e.innerHTML))?(at&&e.nodeType===le&&(t=e.textContent,m([Fe,He,Be],e=>{t=E(t,e," ")}),e.textContent!==t&&(g(o.removed,{element:e.cloneNode()}),e.textContent=t)),tn(Ue.afterSanitizeElements,e,null),!1):(Yt(e),!0)},on=function(e,t,n){if(et[t])return!1;if(dt&&("id"===t||"name"===t)&&(n in r||n in Ht))return!1;const o=Ve[t]||tt.attributeCheck instanceof Function&&tt.attributeCheck(t,e);if(ot&&!et[t]&&C(je,t));else if(nt&&C(Ge,t));else if(!o||et[t]){if(!(an(e)&&(Je.tagNameCheck instanceof RegExp&&C(Je.tagNameCheck,e)||Je.tagNameCheck instanceof Function&&Je.tagNameCheck(e))&&(Je.attributeNameCheck instanceof RegExp&&C(Je.attributeNameCheck,t)||Je.attributeNameCheck instanceof Function&&Je.attributeNameCheck(t,e))||"is"===t&&Je.allowCustomizedBuiltInElements&&(Je.tagNameCheck instanceof RegExp&&C(Je.tagNameCheck,n)||Je.tagNameCheck instanceof Function&&Je.tagNameCheck(n))))return!1}else if(Nt[t]);else if(C(Xe,E(n,Ye,"")));else if("src"!==t&&"xlink:href"!==t&&"href"!==t||"script"===e||0!==_(n,"data:")||!Et[e]){if(rt&&!C(We,E(n,Ye,"")));else if(n)return!1}else;return!0},rn=M({},["annotation-xml","color-profile","font-face","font-face-format","font-face-name","font-face-src","font-face-uri","missing-glyph"]),an=function(e){return!rn[b(e)]&&C(qe,e)},ln=function(e){tn(Ue.beforeSanitizeAttributes,e,null);const t=e.attributes;if(!t||Jt(e))return;const n={attrName:"",attrValue:"",keepAttr:!0,allowedAttributes:Ve,forceKeepAttr:void 0};let r=t.length;for(;r--;){const i=t[r],a=i.name,l=i.namespaceURI,c=i.value,s=Ut(a),u=c;let f="value"===a?u:N(u);if(n.attrName=s,n.attrValue=f,n.keepAttr=!0,n.forceKeepAttr=void 0,tn(Ue.uponSanitizeAttribute,e,n),f=n.attrValue,!ht||"id"!==s&&"name"!==s||0===_(f,gt)||(Xt(a,e),f=gt+f),lt&&C(/((--!?|])>)|<\/(style|script|title|xmp|textarea|noscript|iframe|noembed|noframes)/i,f)){Xt(a,e);continue}if("attributename"===s&&S(f,"href")){Xt(a,e);continue}if(n.forceKeepAttr)continue;if(!n.keepAttr){Xt(a,e);continue}if(!it&&C(/\/>/i,f)){Xt(a,e);continue}at&&m([Fe,He,Be],e=>{f=E(f,e," ")});const p=Ut(e.nodeName);if(on(p,s,f)){if(_e&&"object"==typeof P&&"function"==typeof P.getAttributeType)if(l);else switch(P.getAttributeType(p,s)){case"TrustedHTML":f=ve(f);break;case"TrustedScriptURL":f=Ie(f)}if(f!==u)try{l?e.setAttributeNS(l,a,f):e.setAttribute(a,f),Jt(e)?Yt(e):h(o.removed)}catch(t){Xt(a,e)}}else Xt(a,e)}tn(Ue.afterSanitizeAttributes,e,null)},cn=function(e){let t=null;const n=Vt(e);for(tn(Ue.beforeSanitizeShadowDOM,e,null);t=n.nextNode();){tn(Ue.uponSanitizeShadowNode,t,null),nn(t),ln(t),Qt(t.content)&&cn(t.content);if((Se?Se(t):t.nodeType)===ae){const e=be?be(t):t.shadowRoot;Qt(e)&&(sn(e),cn(e))}}tn(Ue.afterSanitizeShadowDOM,e,null)},sn=function(e){const t=[{node:e,shadow:null}];for(;t.length>0;){const e=t.pop();if(e.shadow){cn(e.shadow);continue}const n=e.node,o=(Se?Se(n):n.nodeType)===ae,r=ye?ye(n):n.childNodes;if(r)for(let e=r.length-1;e>=0;--e)t.push({node:r[e],shadow:null});if(o){const e=Ee?Ee(n):null;if("string"==typeof e&&"template"===Ut(e)){const e=n.content;Qt(e)&&t.push({node:e,shadow:null})}}if(o){const e=be?be(n):n.shadowRoot;Qt(e)&&t.push({node:null,shadow:e},{node:e,shadow:null})}}};return o.sanitize=function(e){let t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{},n=null,r=null,a=null,l=null;if(It=!e,It&&(e="\x3c!--\x3e"),"string"!=typeof e&&!en(e)&&"string"!=typeof(e=function(e){switch(typeof e){case"string":return e;case"number":return O(e);case"boolean":return w(e);case"bigint":return D?D(e):"0";case"symbol":return R?R(e):"Symbol()";case"undefined":default:return I(e);case"function":case"object":{if(null===e)return I(e);const t=e,n=U(t,"toString");if("function"==typeof n){const e=n(t);return"string"==typeof e?e:I(e)}return I(e)}}}(e)))throw x("dirty is not a string, aborting");if(!o.isSupported)return e;st||jt(t),o.removed=[];const c=Tt&&"string"!=typeof e&&en(e);if(c){const t=Ee?Ee(e):e.nodeName;if("string"==typeof t){const e=Ut(t);if(!$e[e]||Qe[e])throw x("root node is forbidden and cannot be sanitized in-place")}if(Jt(e))throw x("root node is clobbered and cannot be sanitized in-place");try{sn(e)}catch(t){throw qt(e),t}}else if(en(e))n=Kt("\x3c!----\x3e"),r=n.ownerDocument.importNode(e,!0),r.nodeType===ae&&"BODY"===r.nodeName||"HTML"===r.nodeName?n=r:n.appendChild(r),sn(r);else{if(!ft&&!at&&!ct&&-1===e.indexOf("<"))return _e&&mt?ve(e):e;if(n=Kt(e),!n)return ft?null:mt?Oe:""}n&&ut&&Yt(n.firstChild);const s=Vt(c?e:n);try{for(;a=s.nextNode();)nn(a),ln(a),Qt(a.content)&&cn(a.content)}catch(t){throw c&&qt(e),t}if(c)return m(o.removed,e=>{e.element&&function(e){const t=[e];for(;t.length>0;){const e=t.pop();(Se?Se(e):e.nodeType)===ae&&$t(e);const n=ye?ye(e):e.childNodes;if(n)for(let e=n.length-1;e>=0;--e)t.push(n[e])}}(e.element)}),at&&Zt(e),e;if(ft){if(at&&Zt(n),pt)for(l=Me.call(n.ownerDocument);n.firstChild;)l.appendChild(n.firstChild);else l=n;return(Ve.shadowroot||Ve.shadowrootmode)&&(l=ze.call(i,l,!0)),l}let u=ct?n.outerHTML:n.innerHTML;return ct&&$e["!doctype"]&&n.ownerDocument&&n.ownerDocument.doctype&&n.ownerDocument.doctype.name&&C(re,n.ownerDocument.doctype.name)&&(u="\n"+u),at&&m([Fe,He,Be],e=>{u=E(u,e," ")}),_e&&mt?ve(u):u},o.setConfig=function(){jt(arguments.length>0&&void 0!==arguments[0]?arguments[0]:{}),st=!0},o.clearConfig=function(){Ft=null,st=!1,_e=Ne,Oe=""},o.isValidAttribute=function(e,t,n){Ft||jt({});const o=Ut(e),r=Ut(t);return on(o,r,n)},o.addHook=function(e,t){"function"==typeof t&&g(Ue[e],t)},o.removeHook=function(e,t){if(void 0!==t){const n=d(Ue[e],t);return-1===n?void 0:y(Ue[e],n,1)[0]}return h(Ue[e])},o.removeHooks=function(e){Ue[e]=[]},o.removeAllHooks=function(){Ue={afterSanitizeAttributes:[],afterSanitizeElements:[],afterSanitizeShadowDOM:[],beforeSanitizeAttributes:[],beforeSanitizeElements:[],beforeSanitizeShadowDOM:[],uponSanitizeAttribute:[],uponSanitizeElement:[],uponSanitizeShadowNode:[]}},o}();return me}); +//# sourceMappingURL=purify.min.js.map diff --git a/lib/app.dart b/lib/app.dart index aff7caa..ca17201 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -62,9 +62,7 @@ class _ConsentGate extends ConsumerWidget { final consent = ref.watch(consentProvider); if (consent.isLoading) { - return const Scaffold( - body: Center(child: CircularProgressIndicator()), - ); + return const Scaffold(body: Center(child: CircularProgressIndicator())); } if (!consent.hasAccepted) { @@ -72,9 +70,7 @@ class _ConsentGate extends ConsumerWidget { // supplies the theme and the AppLocalizations delegate, so context.l10n // resolves here. A nested MaterialApp would start a fresh Localizations // scope without our delegate and the consent text would render blank. - return const Scaffold( - body: Center(child: ConsentDialog()), - ); + return const Scaffold(body: Center(child: ConsentDialog())); } return const AppShell(); diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index aee44d4..e8b0bdd 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -1288,7 +1288,7 @@ const _dutchSourceStrings = { 'Knop': 'Pulsante', 'Profielnaam': 'Nome profilo', 'Stijlprofiel': 'Profilo stile', - 'Lettertype': 'Font', + 'Lettertype': 'Carattere', 'Kleuren': 'Colori', 'Tekst': 'Testo', 'Logo': 'Logo', @@ -1349,15 +1349,20 @@ const _dutchSourceStrings = { 'P publiek · H legenda · S scherm · G overzicht · B/W zwart/wit · R tijd · Esc stop': 'P pubblico · H legenda · S schermo · G panoramica · B/W nero/bianco · R tempo · Esc stop', 'Akkoord gaan': 'Accetto', - 'Alle gegevens die u in OciDeck invoert, blijven op uw lokale systeem en worden niet naar externe servers gestuurd.': 'Tutti i dati che inserisci in OciDeck rimangono sul tuo sistema locale e non vengono inviati a server esterni.', - 'De app verzamelt geen persoonlijke gegevens, geen statistieken en geen gebruiksgegevens. Uw privacy is onze prioriteit.': 'L\'app non raccoglie dati personali, statistiche o dati di utilizzo. La tua privacy è la nostra priorità.', - 'Door op "Akkoord gaan" te klikken, accepteert u deze voorwaarden en gaat u akkoord met het gebruik van OciDeck.': 'Facendo clic su "Accetto", accetti questi termini e accetti l\'uso di OciDeck.', + 'Alle gegevens die u in OciDeck invoert, blijven op uw lokale systeem en worden niet naar externe servers gestuurd.': + 'Tutti i dati che inserisci in OciDeck rimangono sul tuo sistema locale e non vengono inviati a server esterni.', + 'De app verzamelt geen persoonlijke gegevens, geen statistieken en geen gebruiksgegevens. Uw privacy is onze prioriteit.': + 'L\'app non raccoglie dati personali, statistiche o dati di utilizzo. La tua privacy è la nostra priorità.', + 'Door op "Akkoord gaan" te klikken, accepteert u deze voorwaarden en gaat u akkoord met het gebruik van OciDeck.': + 'Facendo clic su "Accetto", accetti questi termini e accetti l\'uso di OciDeck.', 'Licentie (EUPL 1.2)': 'Licenza (EUPL 1.2)', - 'OciDeck is een lokale desktop-applicatie. Uw presentaties en gegevens worden uitsluitend op uw computer opgeslagen.': 'OciDeck è un\'applicazione desktop locale. Le tue presentazioni e i tuoi dati vengono archiviati esclusivamente sul tuo computer.', + 'OciDeck is een lokale desktop-applicatie. Uw presentaties en gegevens worden uitsluitend op uw computer opgeslagen.': + 'OciDeck è un\'applicazione desktop locale. Le tue presentazioni e i tuoi dati vengono archiviati esclusivamente sul tuo computer.', 'Privacy en gebruik': 'Privacy e utilizzo', 'Toestemming ingetrokken': 'Consenso revocato', 'Toestemming intrekken': 'Revoca consenso', - 'U moet eerst de privacy- en gebruiksvoorwaarden accepteren voordat u OciDeck kunt gebruiken.': 'Devi accettare i termini di privacy e utilizzo prima di poter utilizzare OciDeck.', + 'U moet eerst de privacy- en gebruiksvoorwaarden accepteren voordat u OciDeck kunt gebruiken.': + 'Devi accettare i termini di privacy e utilizzo prima di poter utilizzare OciDeck.', 'Volledige licentie online': 'Licenza completa online', 'Welkom bij OciDeck': 'Benvenuto in OciDeck', }, @@ -1516,7 +1521,7 @@ const _dutchSourceStrings = { 'Zoom resetten': 'Zoom zurücksetzen', 'Vorige slide': 'Vorherige Folie', 'Volgende slide': 'Nächste Folie', - 'Thema': 'Theme', + 'Thema': 'Design', 'Afbeelding kiezen': 'Bild auswählen', 'Afbeeldingen laden…': 'Bilder werden geladen…', 'Sluiten (Esc)': 'Schließen (Esc)', @@ -1556,15 +1561,20 @@ const _dutchSourceStrings = { 'P publiek · H legenda · S scherm · G overzicht · B/W zwart/wit · R tijd · Esc stop': 'P Publikum · H Legende · S Bildschirm · G Übersicht · B/W schwarz/weiß · R Zeit · Esc Stopp', 'Akkoord gaan': 'Akzeptieren', - 'Alle gegevens die u in OciDeck invoert, blijven op uw lokale systeem en worden niet naar externe servers gestuurd.': 'Alle Daten, die Sie in OciDeck eingeben, bleiben auf Ihrem lokalen System und werden nicht an externe Server gesendet.', - 'De app verzamelt geen persoonlijke gegevens, geen statistieken en geen gebruiksgegevens. Uw privacy is onze prioriteit.': 'Die App sammelt keine persönlichen Daten, Statistiken oder Nutzungsdaten. Ihre Privatsphäre ist unsere Priorität.', - 'Door op "Akkoord gaan" te klikken, accepteert u deze voorwaarden en gaat u akkoord met het gebruik van OciDeck.': 'Indem Sie auf "Akzeptieren" klicken, akzeptieren Sie diese Bedingungen und stimmen der Verwendung von OciDeck zu.', + 'Alle gegevens die u in OciDeck invoert, blijven op uw lokale systeem en worden niet naar externe servers gestuurd.': + 'Alle Daten, die Sie in OciDeck eingeben, bleiben auf Ihrem lokalen System und werden nicht an externe Server gesendet.', + 'De app verzamelt geen persoonlijke gegevens, geen statistieken en geen gebruiksgegevens. Uw privacy is onze prioriteit.': + 'Die App sammelt keine persönlichen Daten, Statistiken oder Nutzungsdaten. Ihre Privatsphäre ist unsere Priorität.', + 'Door op "Akkoord gaan" te klikken, accepteert u deze voorwaarden en gaat u akkoord met het gebruik van OciDeck.': + 'Indem Sie auf "Akzeptieren" klicken, akzeptieren Sie diese Bedingungen und stimmen der Verwendung von OciDeck zu.', 'Licentie (EUPL 1.2)': 'Lizenz (EUPL 1.2)', - 'OciDeck is een lokale desktop-applicatie. Uw presentaties en gegevens worden uitsluitend op uw computer opgeslagen.': 'OciDeck ist eine lokale Desktop-Anwendung. Ihre Präsentationen und Daten werden ausschließlich auf Ihrem Computer gespeichert.', + 'OciDeck is een lokale desktop-applicatie. Uw presentaties en gegevens worden uitsluitend op uw computer opgeslagen.': + 'OciDeck ist eine lokale Desktop-Anwendung. Ihre Präsentationen und Daten werden ausschließlich auf Ihrem Computer gespeichert.', 'Privacy en gebruik': 'Datenschutz und Verwendung', 'Toestemming ingetrokken': 'Zustimmung widerrufen', 'Toestemming intrekken': 'Zustimmung widerrufen', - 'U moet eerst de privacy- en gebruiksvoorwaarden accepteren voordat u OciDeck kunt gebruiken.': 'Sie müssen die Datenschutz- und Nutzungsbedingungen akzeptieren, bevor Sie OciDeck verwenden können.', + 'U moet eerst de privacy- en gebruiksvoorwaarden accepteren voordat u OciDeck kunt gebruiken.': + 'Sie müssen die Datenschutz- und Nutzungsbedingungen akzeptieren, bevor Sie OciDeck verwenden können.', 'Volledige licentie online': 'Vollständige Lizenz online', 'Welkom bij OciDeck': 'Willkommen bei OciDeck', }, @@ -1762,15 +1772,20 @@ const _dutchSourceStrings = { 'P publiek · H legenda · S scherm · G overzicht · B/W zwart/wit · R tijd · Esc stop': 'P public · H legende · S ecran · G vue d’ensemble · B/W noir/blanc · R temps · Esc arret', 'Akkoord gaan': 'Accepter', - 'Alle gegevens die u in OciDeck invoert, blijven op uw lokale systeem en worden niet naar externe servers gestuurd.': 'Toutes les données que vous saisissez dans OciDeck restent sur votre système local et ne sont pas envoyées à des serveurs externes.', - 'De app verzamelt geen persoonlijke gegevens, geen statistieken en geen gebruiksgegevens. Uw privacy is onze prioriteit.': 'L\'application ne collecte aucune donnée personnelle, statistique ou d\'utilisation. Votre confidentialité est notre priorité.', - 'Door op "Akkoord gaan" te klikken, accepteert u deze voorwaarden en gaat u akkoord met het gebruik van OciDeck.': 'En cliquant sur "Accepter", vous acceptez ces conditions et acceptez l\'utilisation d\'OciDeck.', + 'Alle gegevens die u in OciDeck invoert, blijven op uw lokale systeem en worden niet naar externe servers gestuurd.': + 'Toutes les données que vous saisissez dans OciDeck restent sur votre système local et ne sont pas envoyées à des serveurs externes.', + 'De app verzamelt geen persoonlijke gegevens, geen statistieken en geen gebruiksgegevens. Uw privacy is onze prioriteit.': + 'L\'application ne collecte aucune donnée personnelle, statistique ou d\'utilisation. Votre confidentialité est notre priorité.', + 'Door op "Akkoord gaan" te klikken, accepteert u deze voorwaarden en gaat u akkoord met het gebruik van OciDeck.': + 'En cliquant sur "Accepter", vous acceptez ces conditions et acceptez l\'utilisation d\'OciDeck.', 'Licentie (EUPL 1.2)': 'Licence (EUPL 1.2)', - 'OciDeck is een lokale desktop-applicatie. Uw presentaties en gegevens worden uitsluitend op uw computer opgeslagen.': 'OciDeck est une application de bureau locale. Vos présentations et vos données sont stockées exclusivement sur votre ordinateur.', + 'OciDeck is een lokale desktop-applicatie. Uw presentaties en gegevens worden uitsluitend op uw computer opgeslagen.': + 'OciDeck est une application de bureau locale. Vos présentations et vos données sont stockées exclusivement sur votre ordinateur.', 'Privacy en gebruik': 'Confidentialité et utilisation', 'Toestemming ingetrokken': 'Consentement révoqué', 'Toestemming intrekken': 'Révoquer le consentement', - 'U moet eerst de privacy- en gebruiksvoorwaarden accepteren voordat u OciDeck kunt gebruiken.': 'Vous devez accepter les conditions de confidentialité et d\'utilisation avant de pouvoir utiliser OciDeck.', + 'U moet eerst de privacy- en gebruiksvoorwaarden accepteren voordat u OciDeck kunt gebruiken.': + 'Vous devez accepter les conditions de confidentialité et d\'utilisation avant de pouvoir utiliser OciDeck.', 'Volledige licentie online': 'Licence complète en ligne', 'Welkom bij OciDeck': 'Bienvenue dans OciDeck', }, @@ -1969,15 +1984,20 @@ const _dutchSourceStrings = { 'P publiek · H legenda · S scherm · G overzicht · B/W zwart/wit · R tijd · Esc stop': 'P público · H leyenda · S pantalla · G vista general · B/W negro/blanco · R tiempo · Esc detener', 'Akkoord gaan': 'Aceptar', - 'Alle gegevens die u in OciDeck invoert, blijven op uw lokale systeem en worden niet naar externe servers gestuurd.': 'Todos los datos que ingresa en OciDeck permanecen en su sistema local y no se envían a servidores externos.', - 'De app verzamelt geen persoonlijke gegevens, geen statistieken en geen gebruiksgegevens. Uw privacy is onze prioriteit.': 'La aplicación no recopila datos personales, estadísticas ni datos de uso. Su privacidad es nuestra prioridad.', - 'Door op "Akkoord gaan" te klikken, accepteert u deze voorwaarden en gaat u akkoord met het gebruik van OciDeck.': 'Al hacer clic en "Aceptar", acepta estos términos y acepta el uso de OciDeck.', + 'Alle gegevens die u in OciDeck invoert, blijven op uw lokale systeem en worden niet naar externe servers gestuurd.': + 'Todos los datos que ingresa en OciDeck permanecen en su sistema local y no se envían a servidores externos.', + 'De app verzamelt geen persoonlijke gegevens, geen statistieken en geen gebruiksgegevens. Uw privacy is onze prioriteit.': + 'La aplicación no recopila datos personales, estadísticas ni datos de uso. Su privacidad es nuestra prioridad.', + 'Door op "Akkoord gaan" te klikken, accepteert u deze voorwaarden en gaat u akkoord met het gebruik van OciDeck.': + 'Al hacer clic en "Aceptar", acepta estos términos y acepta el uso de OciDeck.', 'Licentie (EUPL 1.2)': 'Licencia (EUPL 1.2)', - 'OciDeck is een lokale desktop-applicatie. Uw presentaties en gegevens worden uitsluitend op uw computer opgeslagen.': 'OciDeck es una aplicación de escritorio local. Sus presentaciones y datos se almacenan exclusivamente en su ordenador.', + 'OciDeck is een lokale desktop-applicatie. Uw presentaties en gegevens worden uitsluitend op uw computer opgeslagen.': + 'OciDeck es una aplicación de escritorio local. Sus presentaciones y datos se almacenan exclusivamente en su ordenador.', 'Privacy en gebruik': 'Privacidad y uso', 'Toestemming ingetrokken': 'Consentimiento revocado', 'Toestemming intrekken': 'Revocar consentimiento', - 'U moet eerst de privacy- en gebruiksvoorwaarden accepteren voordat u OciDeck kunt gebruiken.': 'Debe aceptar los términos de privacidad y uso antes de poder usar OciDeck.', + 'U moet eerst de privacy- en gebruiksvoorwaarden accepteren voordat u OciDeck kunt gebruiken.': + 'Debe aceptar los términos de privacidad y uso antes de poder usar OciDeck.', 'Volledige licentie online': 'Licencia completa en línea', 'Welkom bij OciDeck': 'Bienvenido a OciDeck', }, @@ -2176,15 +2196,20 @@ const _dutchSourceStrings = { 'P publiek · H legenda · S scherm · G overzicht · B/W zwart/wit · R tijd · Esc stop': 'P publyk · H leginda · S skerm · G oersjoch · B/W swart/wyt · R tiid · Esc stop', 'Akkoord gaan': 'Akseptyf gean', - 'Alle gegevens die u in OciDeck invoert, blijven op uw lokale systeem en worden niet naar externe servers gestuurd.': 'Alle gegevens dy\'t jo yn OciDeck ynfiere, bliuwe op jo lokale systeem en wurde net stjoerd nei eksterne servers.', - 'De app verzamelt geen persoonlijke gegevens, geen statistieken en geen gebruiksgegevens. Uw privacy is onze prioriteit.': 'De app sammelt gjin persoanlike gegevens, statistiken of gebrûksgegevens. Jo privacy is ús prioriteit.', - 'Door op "Akkoord gaan" te klikken, accepteert u deze voorwaarden en gaat u akkoord met het gebruik van OciDeck.': 'Troch op "Akseptyf gean" te klikken, akseptearje jo dizze betingsten en akseptyf jo it brûken fan OciDeck.', + 'Alle gegevens die u in OciDeck invoert, blijven op uw lokale systeem en worden niet naar externe servers gestuurd.': + 'Alle gegevens dy\'t jo yn OciDeck ynfiere, bliuwe op jo lokale systeem en wurde net stjoerd nei eksterne servers.', + 'De app verzamelt geen persoonlijke gegevens, geen statistieken en geen gebruiksgegevens. Uw privacy is onze prioriteit.': + 'De app sammelt gjin persoanlike gegevens, statistiken of gebrûksgegevens. Jo privacy is ús prioriteit.', + 'Door op "Akkoord gaan" te klikken, accepteert u deze voorwaarden en gaat u akkoord met het gebruik van OciDeck.': + 'Troch op "Akseptyf gean" te klikken, akseptearje jo dizze betingsten en akseptyf jo it brûken fan OciDeck.', 'Licentie (EUPL 1.2)': 'Lisintse (EUPL 1.2)', - 'OciDeck is een lokale desktop-applicatie. Uw presentaties en gegevens worden uitsluitend op uw computer opgeslagen.': 'OciDeck is in lokaal bureauprogram. Jo presintatoarjes en gegevens wurde allinnich op jo kompjûter opslein.', + 'OciDeck is een lokale desktop-applicatie. Uw presentaties en gegevens worden uitsluitend op uw computer opgeslagen.': + 'OciDeck is in lokaal bureauprogram. Jo presintatoarjes en gegevens wurde allinnich op jo kompjûter opslein.', 'Privacy en gebruik': 'Privacy en brûk', 'Toestemming ingetrokken': 'Tastimming yntrokken', 'Toestemming intrekken': 'Tastimming yntrekke', - 'U moet eerst de privacy- en gebruiksvoorwaarden accepteren voordat u OciDeck kunt gebruiken.': 'Jo moatte de privacy- en brûksbetingsten akseptyf gean foardat jo OciDeck brûke kûnne.', + 'U moet eerst de privacy- en gebruiksvoorwaarden accepteren voordat u OciDeck kunt gebruiken.': + 'Jo moatte de privacy- en brûksbetingsten akseptyf gean foardat jo OciDeck brûke kûnne.', 'Volledige licentie online': 'Folsleine lisintse online', 'Welkom bij OciDeck': 'Wolkom by OciDeck', }, @@ -2320,7 +2345,7 @@ const _dutchSourceStrings = { 'Knop': 'Boton', 'Profielnaam': 'Nòmber di perfil', 'Stijlprofiel': 'Perfil di estilo', - 'Lettertype': 'Font', + 'Lettertype': 'Tipo di lèter', 'Kleuren': 'Kolónan', 'Tekst': 'Teksto', 'Logo': 'Logo', @@ -2335,18 +2360,18 @@ const _dutchSourceStrings = { 'Links': 'Robes', 'Midden': 'Meimei', 'Rechts': 'Drechi', - 'Voorvertoning': 'Preview', - 'Preview': 'Preview', - 'Uitzoomen': 'Zoom out', - 'Inzoomen': 'Zoom in', - 'Zoom resetten': 'Reset zoom', + 'Voorvertoning': 'Bista previa', + 'Preview': 'Bista previa', + 'Uitzoomen': 'Zoom afó', + 'Inzoomen': 'Zoom paden', + 'Zoom resetten': 'Resetá zoom', 'Vorige slide': 'Slide anterior', 'Volgende slide': 'Siguiente slide', 'Thema': 'Tema', 'Afbeelding kiezen': 'Skohe imágen', 'Afbeeldingen laden…': 'Kargando imágennan…', 'Sluiten (Esc)': 'Sera (Esc)', - 'Raster': 'Grid', + 'Raster': 'Kuadrikula', 'Geen afbeeldingen gevonden': 'No a haña imágen', 'Gekopieerd': 'Kopiá', 'Afbeelding verwijderen?': 'Kita imágen?', @@ -2382,15 +2407,20 @@ const _dutchSourceStrings = { 'P publiek · H legenda · S scherm · G overzicht · B/W zwart/wit · R tijd · Esc stop': 'P públiko · H legenda · S pantalla · G resumen · B/W pretu/blanku · R tempu · Esc stop', 'Akkoord gaan': 'Akuerdo', - 'Alle gegevens die u in OciDeck invoert, blijven op uw lokale systeem en worden niet naar externe servers gestuurd.': 'Tur datos ku bo ta entrá den OciDeck ta keda riba bo sistema lokal i no ta mandá pa servidor eksterno.', - 'De app verzamelt geen persoonlijke gegevens, geen statistieken en geen gebruiksgegevens. Uw privacy is onze prioriteit.': 'E aplikashon no ta kolektá datos personal, estadístika o datos di uso. Bo privacidad ta nos prioridad.', - 'Door op "Akkoord gaan" te klikken, accepteert u deze voorwaarden en gaat u akkoord met het gebruik van OciDeck.': 'Dor ku bo ta klikí "Akuerdo", bo ta akseptá e termino aki i bo ta akuerdo ku e uso di OciDeck.', + 'Alle gegevens die u in OciDeck invoert, blijven op uw lokale systeem en worden niet naar externe servers gestuurd.': + 'Tur datos ku bo ta entrá den OciDeck ta keda riba bo sistema lokal i no ta mandá pa servidor eksterno.', + 'De app verzamelt geen persoonlijke gegevens, geen statistieken en geen gebruiksgegevens. Uw privacy is onze prioriteit.': + 'E aplikashon no ta kolektá datos personal, estadístika o datos di uso. Bo privacidad ta nos prioridad.', + 'Door op "Akkoord gaan" te klikken, accepteert u deze voorwaarden en gaat u akkoord met het gebruik van OciDeck.': + 'Dor ku bo ta klikí "Akuerdo", bo ta akseptá e termino aki i bo ta akuerdo ku e uso di OciDeck.', 'Licentie (EUPL 1.2)': 'Lisencia (EUPL 1.2)', - 'OciDeck is een lokale desktop-applicatie. Uw presentaties en gegevens worden uitsluitend op uw computer opgeslagen.': 'OciDeck ta un programa di buró lokal. Bo presentashon i datos ta almasená solamente riba bo komputer.', + 'OciDeck is een lokale desktop-applicatie. Uw presentaties en gegevens worden uitsluitend op uw computer opgeslagen.': + 'OciDeck ta un programa di buró lokal. Bo presentashon i datos ta almasená solamente riba bo komputer.', 'Privacy en gebruik': 'Privacidad i Uso', 'Toestemming ingetrokken': 'Aprobashon retirá', 'Toestemming intrekken': 'Retirá Aprobashon', - 'U moet eerst de privacy- en gebruiksvoorwaarden accepteren voordat u OciDeck kunt gebruiken.': 'Bo mester akseptá e termino di privacidad i uso antes bo por usa OciDeck.', + 'U moet eerst de privacy- en gebruiksvoorwaarden accepteren voordat u OciDeck kunt gebruiken.': + 'Bo mester akseptá e termino di privacidad i uso antes bo por usa OciDeck.', 'Volledige licentie online': 'Lisencia kompleto online', 'Welkom bij OciDeck': 'Bienvenido na OciDeck', }, @@ -2627,203 +2657,208 @@ const _dutchSourceStringAdditions = { 'Aflopend sorteren': 'Ordina in modo decrescente', 'Bij een cirkel worden maximaal de eerste twee reeksen getoond; de labels vormen de segmenten.': 'I grafici a torta mostrano al massimo le prime due serie; le etichette formano i segmenti.', - '# Slide\n\nInhoud hier...': '# Slide\n\nContent here...', - '1 slide geïmporteerd.': '1 slide imported.', - '1 slide kopiëren naar…': 'Copy 1 slide to…', - '1 slide overgeslagen': '1 slide skipped', + '# Slide\n\nInhoud hier...': '# Diapositiva\n\nContenuto qui...', + '1 slide geïmporteerd.': '1 diapositiva importata.', + '1 slide kopiëren naar…': 'Copia 1 diapositiva in…', + '1 slide overgeslagen': '1 diapositiva saltata', 'Accent / bullets': 'Accent / bullets', - 'Achtergrond slides': 'Slide background', + 'Achtergrond slides': 'Sfondo diapositive', 'Afbeelding': 'Immagine', - 'Afbeelding gekopieerd naar klembord.': 'Image copied to clipboard.', - 'Afbeelding plakken': 'Paste image', - 'Afbeelding plakken uit klembord': 'Paste image from clipboard', + 'Afbeelding gekopieerd naar klembord.': 'Immagine copiata negli appunti.', + 'Afbeelding plakken': 'Incolla immagine', + 'Afbeelding plakken uit klembord': 'Incolla immagine dagli appunti', 'Afbeeldingen → nieuwe slides · .md / .ocideck → openen': - 'Images → new slides · .md / .ocideck → open', - 'Afsluiten (Escape)': 'Exit (Escape)', + 'Immagini → nuove diapositive · .md / .ocideck → apri', + 'Afsluiten (Escape)': 'Esci (Esc)', 'Alle slides zijn overgeslagen — niets om te exporteren.': - 'All slides are skipped, so there is nothing to export.', + 'Tutte le diapositive sono state saltate: non c\'è nulla da esportare.', 'Alle slides zijn overgeslagen — niets om te tonen.': - 'All slides are skipped, so there is nothing to show.', - 'Alles tonen': 'Show all', - 'Audio verwijderen': 'Remove audio', - 'Automatisch doorgaan na': 'Advance automatically after', - 'Bijv. Kwartaalupdate Q4': 'E.g. Q4 update', + 'Tutte le diapositive sono state saltate: non c\'è nulla da mostrare.', + 'Alles tonen': 'Mostra tutto', + 'Audio verwijderen': 'Rimuovi audio', + 'Automatisch doorgaan na': 'Avanza automaticamente dopo', + 'Bijv. Kwartaalupdate Q4': 'Es. Aggiornamento Q4', 'Bullet': 'Punto elenco', 'Caption / bronvermelding (bijv. © Naam Fotograaf)': - 'Caption / credit (e.g. © Photographer Name)', + 'Didascalia / crediti (es. © Nome del fotografo)', 'Coverflow': 'Coverflow', 'De afbeelding wordt schermvullend als achtergrond getoond met verminderde opaciteit zodat de tekst leesbaar blijft.': - 'The image is shown fullscreen as a background with reduced opacity so the text remains readable.', + 'L\'immagine viene mostrata a schermo intero come sfondo con opacità ridotta affinché il testo resti leggibile.', 'De snelle bruine vos springt over de luie hond.': - 'The quick brown fox jumps over the lazy dog.', + 'Ma la volpe, col suo balzo, ha raggiunto il quieto Fido.', 'Deze gegevens worden in de markdown opgeslagen en zijn doorzoekbaar bij het openen.': - 'These details are stored in the Markdown and searchable when opening.', + 'Questi dati vengono salvati nel Markdown e sono ricercabili all\'apertura.', 'Deze presentatie heeft niet-opgeslagen wijzigingen. Sla de presentatie op voordat het tabblad sluit.': - 'This presentation has unsaved changes. Save it before closing the tab.', + 'Questa presentazione ha modifiche non salvate. Salvala prima di chiudere la scheda.', 'Deze slide kan geen afbeelding ontvangen. Kies eerst een afbeeldingsslide.': - 'This slide cannot receive an image. Choose an image slide first.', - 'Eerste': 'First', - 'Einde van de presentatie': 'End of presentation', + 'Questa diapositiva non può ricevere un\'immagine. Scegli prima una diapositiva immagine.', + 'Eerste': 'Prima', + 'Einde van de presentatie': 'Fine della presentazione', 'Er is een presentatie met niet-opgeslagen wijzigingen gevonden van een vorige sessie:': - 'A presentation with unsaved changes was found from a previous session:', - 'Er zijn': 'There are', + 'È stata trovata una presentazione con modifiche non salvate di una sessione precedente:', + 'Er zijn': 'Ci sono', 'Er zijn presentaties met niet-opgeslagen wijzigingen. Sla ze op voordat de app sluit.': - 'There are presentations with unsaved changes. Save them before closing the app.', - 'Export mislukt:': 'Export failed:', - 'Footer tonen op deze slide': 'Show footer on this slide', + 'Ci sono presentazioni con modifiche non salvate. Salvale prima di chiudere l\'app.', + 'Export mislukt:': 'Esportazione non riuscita:', + 'Footer tonen op deze slide': + 'Mostra il piè di pagina su questa diapositiva', 'Gebruik "Bladeren" om afbeeldingen van elke locatie te kiezen.': - 'Use “Browse” to choose images from any location.', + 'Usa "Sfoglia" per scegliere immagini da qualsiasi posizione.', 'Geen afbeelding op het klembord gevonden.': - 'No image found on the clipboard.', + 'Nessuna immagine trovata negli appunti.', 'Geen ander deck open. Open eerst een ander tabblad.': - 'No other deck is open. Open another tab first.', + 'Nessun altro deck aperto. Apri prima un\'altra scheda.', 'Geen andere presentaties (.md) in deze map gevonden.': - 'No other presentations (.md) found in this folder.', - 'Geen notities voor deze slide.': 'No notes for this slide.', + 'Nessun\'altra presentazione (.md) trovata in questa cartella.', + 'Geen notities voor deze slide.': 'Nessuna nota per questa diapositiva.', 'Geen presentaties (.md) in deze map gevonden.': - 'No presentations (.md) found in this folder.', - 'Geen presentaties gevonden voor': 'No presentations found for', - 'Geen resultaten': 'No results', - 'Geen resultaten voor': 'No results for', - 'Geen slides gevonden voor': 'No slides found for', - 'Geen slides met': 'No slides with', - 'Geselecteerd': 'Selected', + 'Nessuna presentazione (.md) trovata in questa cartella.', + 'Geen presentaties gevonden voor': 'Nessuna presentazione trovata per', + 'Geen resultaten': 'Nessun risultato', + 'Geen resultaten voor': 'Nessun risultato per', + 'Geen slides gevonden voor': 'Nessuna diapositiva trovata per', + 'Geen slides met': 'Nessuna diapositiva con', + 'Geselecteerd': 'Selezionata', 'HTML opent in elke browser zonder internet en rendert codeblokken, wiskunde en mermaid-diagrammen.': 'L’HTML si apre in qualsiasi browser senza internet e renderizza blocchi di codice, matematica e diagrammi Mermaid.', 'Het bestand wordt permanent van schijf verwijderd. Deze actie kan niet ongedaan worden gemaakt.': - 'The file will be permanently deleted from disk. This action cannot be undone.', - 'Ingezoomd': 'Zoomed in', + 'Il file verrà eliminato definitivamente dal disco. Questa azione non può essere annullata.', + 'Ingezoomd': 'Ingrandita', 'Inzoomen (minder van de foto zichtbaar)': - 'Zoom in (less of the photo visible)', - 'Kies een afbeelding': 'Choose an image', + 'Ingrandisci (meno foto visibile)', + 'Kies een afbeelding': 'Scegli un\'immagine', 'Kies een map met presentaties om te beginnen.': - 'Choose a folder with presentations to begin.', - 'Kon dit pakket niet importeren.': 'Could not import this package.', - 'Kon niet van scherm wisselen.': 'Could not switch screens.', + 'Scegli una cartella con presentazioni per iniziare.', + 'Kon dit pakket niet importeren.': + 'Impossibile importare questo pacchetto.', + 'Kon niet van scherm wisselen.': 'Impossibile cambiare schermo.', 'Kon van deze URL geen presentatie ophalen.': - 'Could not fetch a presentation from this URL.', - 'Kopieer afbeelding naar klembord': 'Copy image to clipboard', - 'Kopiëren mislukt.': 'Copy failed.', - 'Kopiëren naar ander deck': 'Copy to another deck', - 'Kopiëren naar klembord mislukt.': 'Copying to clipboard failed.', - 'Koprij verwijderen': 'Remove header row', - 'Laat los om toe te voegen': 'Release to add', + 'Impossibile recuperare una presentazione da questo URL.', + 'Kopieer afbeelding naar klembord': 'Copia immagine negli appunti', + 'Kopiëren mislukt.': 'Copia non riuscita.', + 'Kopiëren naar ander deck': 'Copia in un altro deck', + 'Kopiëren naar klembord mislukt.': 'Copia negli appunti non riuscita.', + 'Koprij verwijderen': 'Rimuovi riga di intestazione', + 'Laat los om toe te voegen': 'Rilascia per aggiungere', 'Laatste slide': 'Slide finale', 'Let op: deze afbeelding wordt nog gebruikt in': - 'Warning: this image is still used in', - 'Logo kiezen': 'Choose logo', + 'Attenzione: questa immagine è ancora usata in', + 'Logo kiezen': 'Scegli logo', 'Logo px': 'Logo px', - 'Logo tonen op deze slide': 'Show logo on this slide', - 'Map met presentaties kiezen': 'Choose presentation folder', - 'Map voor exports': 'Export folder', + 'Logo tonen op deze slide': 'Mostra il logo su questa diapositiva', + 'Map met presentaties kiezen': 'Scegli la cartella delle presentazioni', + 'Map voor exports': 'Cartella per le esportazioni', 'Markdown kon niet worden verwerkt. Controleer de syntax.': - 'Markdown could not be processed. Check the syntax.', + 'Impossibile elaborare il Markdown. Controlla la sintassi.', 'Markdown modus — bewerk de volledige presentatie als Marp Markdown': - 'Markdown mode — edit the full presentation as Marp Markdown', + 'Modalità Markdown: modifica l\'intera presentazione come Marp Markdown', 'Markdown voor laatste slide': 'Markdown per la slide finale', - 'Naam van het stijlprofiel': 'Name of the style profile', - 'Niet-opgeslagen werk herstellen?': 'Restore unsaved work?', - 'Niet-opgeslagen wijzigingen': 'Unsaved changes', - 'Niets vervangen': 'Nothing replaced', - 'Nieuw profiel': 'New profile', + 'Naam van het stijlprofiel': 'Nome del profilo di stile', + 'Niet-opgeslagen werk herstellen?': 'Ripristinare il lavoro non salvato?', + 'Niet-opgeslagen wijzigingen': 'Modifiche non salvate', + 'Niets vervangen': 'Nulla sostituito', + 'Nieuw profiel': 'Nuovo profilo', 'Open eerst een presentatie om afbeeldingen toe te voegen.': - 'Open a presentation before adding images.', - 'Overslaan bij presenteren/exporteren': 'Skip when presenting/exporting', + 'Apri prima una presentazione per aggiungere immagini.', + 'Overslaan bij presenteren/exporteren': + 'Salta durante presentazione/esportazione', 'PREVIEW': 'ANTEPRIMA', - 'Paginanummers tonen (rechtsonder)': 'Show page numbers (bottom right)', - 'Pakket geëxporteerd naar:': 'Package exported to:', + 'Paginanummers tonen (rechtsonder)': + 'Mostra i numeri di pagina (in basso a destra)', + 'Pakket geëxporteerd naar:': 'Pacchetto esportato in:', 'Pas je zoekterm aan of voeg een beschrijving toe.': - 'Adjust your search term or add a description.', + 'Modifica il termine di ricerca o aggiungi una descrizione.', 'Plak de link naar een .ocideck-pakket of een Marp-markdownbestand.': - 'Paste the link to an .ocideck package or a Marp Markdown file.', - 'Preview inklappen': 'Collapse preview', - 'Preview uitklappen': 'Expand preview', - 'Profiel verwijderen': 'Delete profile', - 'Rij verwijderen': 'Remove row', + 'Incolla il link a un pacchetto .ocideck o a un file Markdown Marp.', + 'Preview inklappen': 'Comprimi anteprima', + 'Preview uitklappen': 'Espandi anteprima', + 'Profiel verwijderen': 'Elimina profilo', + 'Rij verwijderen': 'Rimuovi riga', 'SLIDES': 'SLIDES', - 'Sectieachtergrond': 'Section background', - 'Selecteer een\nafbeelding': 'Select an\nimage', - 'Selectie opheffen': 'Clear selection', + 'Sectieachtergrond': 'Sfondo della sezione', + 'Selecteer een\nafbeelding': 'Seleziona\nun\'immagine', + 'Selectie opheffen': 'Annulla selezione', 'Sleep om de slide-preview breder of smaller te maken': - 'Drag to make the slide preview wider or narrower', + 'Trascina per allargare o restringere l\'anteprima della diapositiva', 'Slide': 'Slide', - 'Slide gekopieerd naar klembord.': 'Slide copied to clipboard.', - 'Slide plakken': 'Paste slide', - 'Slide renderen…': 'Rendering slide…', - 'Slide toevoegen': 'Add slide', + 'Slide gekopieerd naar klembord.': 'Diapositiva copiata negli appunti.', + 'Slide plakken': 'Incolla diapositiva', + 'Slide renderen…': 'Rendering diapositiva…', + 'Slide toevoegen': 'Aggiungi diapositiva', 'Slides gerenderd.': 'Slide renderizzate.', - 'Sluiten (G of Esc)': 'Close (G or Esc)', - 'Sprekersnotities...': 'Speaker notes...', + 'Sluiten (G of Esc)': 'Chiudi (G o Esc)', + 'Sprekersnotities...': 'Note del relatore...', 'Standaard laatste slide gebruiken': 'Usa slide finale predefinita', - 'Standaard map voor presentaties': 'Default presentation folder', - 'Standaardprofiel laden': 'Load default profile', + 'Standaard map voor presentaties': + 'Cartella predefinita per le presentazioni', + 'Standaardprofiel laden': 'Carica profilo predefinito', 'TLP-classificatie (Traffic Light Protocol)': - 'TLP classification (Traffic Light Protocol)', - 'Tabel koptekst': 'Table header text', - 'Tabeltekst': 'Table text', - 'Terug naar standaardstijl': 'Back to default style', + 'Classificazione TLP (Traffic Light Protocol)', + 'Tabel koptekst': 'Testo intestazione tabella', + 'Tabeltekst': 'Testo tabella', + 'Terug naar standaardstijl': 'Torna allo stile predefinito', 'Terugzetten (volledige afbeelding zichtbaar)': - 'Reset (full image visible)', - 'Tijd resetten (R)': 'Reset timer (R)', + 'Ripristina (immagine intera visibile)', + 'Tijd resetten (R)': 'Reimposta tempo (R)', 'Tip: druk op Enter binnen een cel voor een nieuwe regel.': - 'Tip: press Enter inside a cell for a new line.', - 'Titelachtergrond': 'Title background', - 'Titeltekst': 'Title text', - 'Toepassen': 'Apply', + 'Suggerimento: premi Invio all\'interno di una cella per andare a capo.', + 'Titelachtergrond': 'Sfondo del titolo', + 'Titeltekst': 'Testo del titolo', + 'Toepassen': 'Applica', 'Tokens: {page}, {total}, {date}, {title}. Footer verschijnt op alle slides behalve titel- en sectieslides, tenzij je hem per slide uitzet.': - 'Tokens: {page}, {total}, {date}, {title}. The footer appears on all slides except title and section slides, unless you disable it per slide.', + 'Token: {page}, {total}, {date}, {title}. Il piè di pagina appare su tutte le diapositive tranne quelle titolo e sezione, a meno che non lo disattivi per singola diapositiva.', 'Typ zoektermen om slides uit al je presentaties te vinden.': - 'Type search terms to find slides across your presentations.', - 'Uitgezoomd': 'Zoomed out', + 'Digita i termini di ricerca per trovare diapositive in tutte le tue presentazioni.', + 'Uitgezoomd': 'Rimpicciolita', 'Uitzoomen (meer van de foto zichtbaar)': - 'Zoom out (more of the photo visible)', - 'Verwijder afbeelding': 'Remove image', - 'Verwijder logo': 'Remove logo', + 'Rimpicciolisci (più foto visibile)', + 'Verwijder afbeelding': 'Rimuovi immagine', + 'Verwijder logo': 'Rimuovi logo', 'Verwijderen maakt die slides leeg. Dit kan niet ongedaan worden gemaakt.': - 'Deleting will clear those slides. This cannot be undone.', - 'Volledig zichtbaar (100%)': 'Fully visible (100%)', - 'Vul een titel in': 'Enter a title', - 'Weer tonen': 'Show again', + 'L\'eliminazione svuota quelle diapositive. Questa operazione non può essere annullata.', + 'Volledig zichtbaar (100%)': 'Completamente visibile (100%)', + 'Vul een titel in': 'Inserisci un titolo', + 'Weer tonen': 'Mostra di nuovo', 'Weer tonen bij presenteren/exporteren': - 'Show again when presenting/exporting', + 'Mostra di nuovo durante presentazione/esportazione', 'Wordt automatisch toegevoegd bij presenteren en exporteren.': 'Aggiunta automaticamente durante la presentazione e l’esportazione.', - 'Zoek in slides…': 'Search in slides…', + 'Zoek in slides…': 'Cerca nelle diapositive…', 'Zoek op bestandsnaam, titel of tekst in de slides…': - 'Search by file name, title or slide text…', - 'Zoek op naam of beschrijving…': 'Search by name or description…', + 'Cerca per nome file, titolo o testo nelle diapositive…', + 'Zoek op naam of beschrijving…': 'Cerca per nome o descrizione…', 'Zoek op presentatie, titel of tekst…': - 'Search by presentation, title or text…', + 'Cerca per presentazione, titolo o testo…', 'Zoek slides op tekst, titel, onderschrift, pad…': - 'Search slides by text, title, caption, path…', + 'Cerca diapositive per testo, titolo, didascalia, percorso…', 'bijv. Vertrouwelijk · {title} · {date}': - 'e.g. Confidential · {title} · {date}', + 'es. Riservato · {title} · {date}', 'gerenderd.': 'renderizzata.', - 'geselecteerd': 'selected', - 'meer treffer(s)': 'more match(es)', - 'paginering aan': 'pagination on', + 'geselecteerd': 'selezionata', + 'meer treffer(s)': 'altri risultati', + 'paginering aan': 'impaginazione attiva', 'pijltjes + Enter of klik om te springen': - 'arrows + Enter or click to jump', + 'frecce + Invio o clic per saltare', 'presentaties met niet-opgeslagen wijzigingen gevonden van een vorige sessie:': - 'presentations with unsaved changes from a previous session:', + 'presentazioni con modifiche non salvate trovate da una sessione precedente:', 'renderen…': 'renderizzazione…', - 'resultaat': 'result', - 'resultaten': 'results', + 'resultaat': 'risultato', + 'resultaten': 'risultati', 'slide': 'slide', - 'slide(s) gekopieerd naar': 'slide(s) copied to', - 'slides geïmporteerd.': 'slides imported.', - 'slides kopiëren naar…': 'slides to copy to…', - 'slides overgeslagen': 'slides skipped', - 'toegevoegd': 'added', - 'treffer(s)': 'match(es)', - 'treffers — verfijn je zoekopdracht': 'matches, refine your search', - 'van de foto zichtbaar': 'of the photo visible', - 'vervangen': 'replaced', - 'verwijderen': 'remove', - 'volledig deck': 'full deck', + 'slide(s) gekopieerd naar': 'diapositiva/e copiata/e in', + 'slides geïmporteerd.': 'diapositive importate.', + 'slides kopiëren naar…': 'diapositive da copiare in…', + 'slides overgeslagen': 'diapositive saltate', + 'toegevoegd': 'aggiunta', + 'treffer(s)': 'risultato/i', + 'treffers — verfijn je zoekopdracht': 'risultati: affina la ricerca', + 'van de foto zichtbaar': 'della foto visibile', + 'vervangen': 'sostituito', + 'verwijderen': 'rimuovi', + 'volledig deck': 'deck completo', 'voorbereiden…': 'preparazione…', '↑↓←→ navigeren · Enter kiezen · Dubbelklik selecteert': - '↑↓←→ navigate · Enter chooses · Double-click selects', + '↑↓←→ naviga · Invio sceglie · Doppio clic seleziona', 'Duplicaten opruimen': 'Rimuovi duplicati', 'Zoek byte-identieke afbeeldingen (md5), voeg tags en opmerkingen samen en verwijder de kopieën': 'Trova immagini identiche byte per byte (md5), unisci tag e note ed elimina le copie', @@ -2838,8 +2873,7 @@ const _dutchSourceStringAdditions = { 'niet geopend': 'non aperto', '1 dubbele afbeelding verwijderd.': '1 immagine duplicata eliminata.', 'dubbele afbeeldingen verwijderd.': 'immagini duplicate eliminate.', - 'Alleen afbeeldingen zonder tags tonen': - 'Mostra solo immagini senza tag', + 'Alleen afbeeldingen zonder tags tonen': 'Mostra solo immagini senza tag', 'Alle afbeeldingen hebben tags.': 'Tutte le immagini hanno tag.', 'Zet het filter uit om alles weer te zien.': 'Disattiva il filtro per rivedere tutto.', @@ -2916,7 +2950,7 @@ const _dutchSourceStringAdditions = { 'Kop (optioneel)': 'Überschrift (optional)', 'Subkop (optioneel)': 'Unterüberschrift (optional)', 'Subkop': 'Unterüberschrift', - 'Systeem (monospace)': 'System (monospace)', + 'Systeem (monospace)': 'System (Monospace)', 'Kleur van reeks': 'Reihenfarbe', 'Kleur van rij': 'Zeilenfarbe', 'Hexkleur': 'Hex-Farbe', @@ -2925,204 +2959,210 @@ const _dutchSourceStringAdditions = { 'Aflopend sorteren': 'Absteigend sortieren', 'Bij een cirkel worden maximaal de eerste twee reeksen getoond; de labels vormen de segmenten.': 'Kreisdiagramme zeigen höchstens die ersten zwei Reihen; die Beschriftungen bilden die Segmente.', - '# Slide\n\nInhoud hier...': '# Slide\n\nContent here...', - '1 slide geïmporteerd.': '1 slide imported.', - '1 slide kopiëren naar…': 'Copy 1 slide to…', - '1 slide overgeslagen': '1 slide skipped', + '# Slide\n\nInhoud hier...': '# Folie\n\nInhalt hier...', + '1 slide geïmporteerd.': '1 Folie importiert.', + '1 slide kopiëren naar…': '1 Folie kopieren nach…', + '1 slide overgeslagen': '1 Folie übersprungen', 'Accent / bullets': 'Accent / bullets', - 'Achtergrond slides': 'Slide background', + 'Achtergrond slides': 'Folienhintergrund', 'Afbeelding': 'Bild', - 'Afbeelding gekopieerd naar klembord.': 'Image copied to clipboard.', - 'Afbeelding plakken': 'Paste image', - 'Afbeelding plakken uit klembord': 'Paste image from clipboard', + 'Afbeelding gekopieerd naar klembord.': + 'Bild in die Zwischenablage kopiert.', + 'Afbeelding plakken': 'Bild einfügen', + 'Afbeelding plakken uit klembord': 'Bild aus der Zwischenablage einfügen', 'Afbeeldingen → nieuwe slides · .md / .ocideck → openen': - 'Images → new slides · .md / .ocideck → open', - 'Afsluiten (Escape)': 'Exit (Escape)', + 'Bilder → neue Folien · .md / .ocideck → öffnen', + 'Afsluiten (Escape)': 'Beenden (Escape)', 'Alle slides zijn overgeslagen — niets om te exporteren.': - 'All slides are skipped, so there is nothing to export.', + 'Alle Folien sind übersprungen — nichts zu exportieren.', 'Alle slides zijn overgeslagen — niets om te tonen.': - 'All slides are skipped, so there is nothing to show.', - 'Alles tonen': 'Show all', - 'Audio verwijderen': 'Remove audio', - 'Automatisch doorgaan na': 'Advance automatically after', - 'Bijv. Kwartaalupdate Q4': 'E.g. Q4 update', + 'Alle Folien sind übersprungen — nichts anzuzeigen.', + 'Alles tonen': 'Alle anzeigen', + 'Audio verwijderen': 'Audio entfernen', + 'Automatisch doorgaan na': 'Automatisch weiter nach', + 'Bijv. Kwartaalupdate Q4': 'Z. B. Quartalsupdate Q4', 'Bullet': 'Stichpunkt', 'Caption / bronvermelding (bijv. © Naam Fotograaf)': - 'Caption / credit (e.g. © Photographer Name)', + 'Bildunterschrift / Quellenangabe (z. B. © Name des Fotografen)', 'Coverflow': 'Coverflow', 'De afbeelding wordt schermvullend als achtergrond getoond met verminderde opaciteit zodat de tekst leesbaar blijft.': - 'The image is shown fullscreen as a background with reduced opacity so the text remains readable.', + 'Das Bild wird bildschirmfüllend als Hintergrund mit reduzierter Deckkraft angezeigt, damit der Text lesbar bleibt.', 'De snelle bruine vos springt over de luie hond.': - 'The quick brown fox jumps over the lazy dog.', + 'Der schnelle braune Fuchs springt über den faulen Hund.', 'Deze gegevens worden in de markdown opgeslagen en zijn doorzoekbaar bij het openen.': - 'These details are stored in the Markdown and searchable when opening.', + 'Diese Daten werden im Markdown gespeichert und sind beim Öffnen durchsuchbar.', 'Deze presentatie heeft niet-opgeslagen wijzigingen. Sla de presentatie op voordat het tabblad sluit.': - 'This presentation has unsaved changes. Save it before closing the tab.', + 'Diese Präsentation hat nicht gespeicherte Änderungen. Speichern Sie sie, bevor der Tab geschlossen wird.', 'Deze slide kan geen afbeelding ontvangen. Kies eerst een afbeeldingsslide.': - 'This slide cannot receive an image. Choose an image slide first.', - 'Eerste': 'First', - 'Einde van de presentatie': 'End of presentation', + 'Diese Folie kann kein Bild aufnehmen. Wählen Sie zuerst eine Bildfolie.', + 'Eerste': 'Erste', + 'Einde van de presentatie': 'Ende der Präsentation', 'Er is een presentatie met niet-opgeslagen wijzigingen gevonden van een vorige sessie:': - 'A presentation with unsaved changes was found from a previous session:', - 'Er zijn': 'There are', + 'Es wurde eine Präsentation mit nicht gespeicherten Änderungen aus einer früheren Sitzung gefunden:', + 'Er zijn': 'Es gibt', 'Er zijn presentaties met niet-opgeslagen wijzigingen. Sla ze op voordat de app sluit.': - 'There are presentations with unsaved changes. Save them before closing the app.', - 'Export mislukt:': 'Export failed:', - 'Footer tonen op deze slide': 'Show footer on this slide', + 'Es gibt Präsentationen mit nicht gespeicherten Änderungen. Speichern Sie sie, bevor die App geschlossen wird.', + 'Export mislukt:': 'Export fehlgeschlagen:', + 'Footer tonen op deze slide': 'Fußzeile auf dieser Folie anzeigen', 'Gebruik "Bladeren" om afbeeldingen van elke locatie te kiezen.': - 'Use “Browse” to choose images from any location.', + 'Verwenden Sie "Durchsuchen", um Bilder von einem beliebigen Ort auszuwählen.', 'Geen afbeelding op het klembord gevonden.': - 'No image found on the clipboard.', + 'Kein Bild in der Zwischenablage gefunden.', 'Geen ander deck open. Open eerst een ander tabblad.': - 'No other deck is open. Open another tab first.', + 'Kein anderes Deck geöffnet. Öffnen Sie zuerst einen anderen Tab.', 'Geen andere presentaties (.md) in deze map gevonden.': - 'No other presentations (.md) found in this folder.', - 'Geen notities voor deze slide.': 'No notes for this slide.', + 'Keine anderen Präsentationen (.md) in diesem Ordner gefunden.', + 'Geen notities voor deze slide.': 'Keine Notizen für diese Folie.', 'Geen presentaties (.md) in deze map gevonden.': - 'No presentations (.md) found in this folder.', - 'Geen presentaties gevonden voor': 'No presentations found for', - 'Geen resultaten': 'No results', - 'Geen resultaten voor': 'No results for', - 'Geen slides gevonden voor': 'No slides found for', - 'Geen slides met': 'No slides with', - 'Geselecteerd': 'Selected', + 'Keine Präsentationen (.md) in diesem Ordner gefunden.', + 'Geen presentaties gevonden voor': 'Keine Präsentationen gefunden für', + 'Geen resultaten': 'Keine Ergebnisse', + 'Geen resultaten voor': 'Keine Ergebnisse für', + 'Geen slides gevonden voor': 'Keine Folien gefunden für', + 'Geen slides met': 'Keine Folien mit', + 'Geselecteerd': 'Ausgewählt', 'HTML opent in elke browser zonder internet en rendert codeblokken, wiskunde en mermaid-diagrammen.': 'HTML öffnet sich in jedem Browser ohne Internet und rendert Codeblöcke, Mathematik und Mermaid-Diagramme.', 'Het bestand wordt permanent van schijf verwijderd. Deze actie kan niet ongedaan worden gemaakt.': - 'The file will be permanently deleted from disk. This action cannot be undone.', - 'Ingezoomd': 'Zoomed in', + 'Die Datei wird dauerhaft von der Festplatte gelöscht. Diese Aktion kann nicht rückgängig gemacht werden.', + 'Ingezoomd': 'Vergrößert', 'Inzoomen (minder van de foto zichtbaar)': - 'Zoom in (less of the photo visible)', - 'Kies een afbeelding': 'Choose an image', + 'Vergrößern (weniger vom Foto sichtbar)', + 'Kies een afbeelding': 'Bild auswählen', 'Kies een map met presentaties om te beginnen.': - 'Choose a folder with presentations to begin.', - 'Kon dit pakket niet importeren.': 'Could not import this package.', - 'Kon niet van scherm wisselen.': 'Could not switch screens.', + 'Wählen Sie einen Ordner mit Präsentationen, um zu beginnen.', + 'Kon dit pakket niet importeren.': + 'Dieses Paket konnte nicht importiert werden.', + 'Kon niet van scherm wisselen.': + 'Bildschirm konnte nicht gewechselt werden.', 'Kon van deze URL geen presentatie ophalen.': - 'Could not fetch a presentation from this URL.', - 'Kopieer afbeelding naar klembord': 'Copy image to clipboard', - 'Kopiëren mislukt.': 'Copy failed.', - 'Kopiëren naar ander deck': 'Copy to another deck', - 'Kopiëren naar klembord mislukt.': 'Copying to clipboard failed.', - 'Koprij verwijderen': 'Remove header row', - 'Laat los om toe te voegen': 'Release to add', + 'Von dieser URL konnte keine Präsentation abgerufen werden.', + 'Kopieer afbeelding naar klembord': 'Bild in die Zwischenablage kopieren', + 'Kopiëren mislukt.': 'Kopieren fehlgeschlagen.', + 'Kopiëren naar ander deck': 'In anderes Deck kopieren', + 'Kopiëren naar klembord mislukt.': + 'Kopieren in die Zwischenablage fehlgeschlagen.', + 'Koprij verwijderen': 'Kopfzeile entfernen', + 'Laat los om toe te voegen': 'Loslassen zum Hinzufügen', 'Laatste slide': 'Letzte Folie', 'Let op: deze afbeelding wordt nog gebruikt in': - 'Warning: this image is still used in', - 'Logo kiezen': 'Choose logo', + 'Achtung: Dieses Bild wird noch verwendet in', + 'Logo kiezen': 'Logo auswählen', 'Logo px': 'Logo px', - 'Logo tonen op deze slide': 'Show logo on this slide', - 'Map met presentaties kiezen': 'Choose presentation folder', - 'Map voor exports': 'Export folder', + 'Logo tonen op deze slide': 'Logo auf dieser Folie anzeigen', + 'Map met presentaties kiezen': 'Präsentationsordner auswählen', + 'Map voor exports': 'Ordner für Exporte', 'Markdown kon niet worden verwerkt. Controleer de syntax.': - 'Markdown could not be processed. Check the syntax.', + 'Markdown konnte nicht verarbeitet werden. Überprüfen Sie die Syntax.', 'Markdown modus — bewerk de volledige presentatie als Marp Markdown': - 'Markdown mode — edit the full presentation as Marp Markdown', + 'Markdown-Modus — bearbeiten Sie die gesamte Präsentation als Marp-Markdown', 'Markdown voor laatste slide': 'Markdown für die letzte Folie', - 'Naam van het stijlprofiel': 'Name of the style profile', - 'Niet-opgeslagen werk herstellen?': 'Restore unsaved work?', - 'Niet-opgeslagen wijzigingen': 'Unsaved changes', - 'Niets vervangen': 'Nothing replaced', - 'Nieuw profiel': 'New profile', + 'Naam van het stijlprofiel': 'Name des Stilprofils', + 'Niet-opgeslagen werk herstellen?': + 'Nicht gespeicherte Arbeit wiederherstellen?', + 'Niet-opgeslagen wijzigingen': 'Nicht gespeicherte Änderungen', + 'Niets vervangen': 'Nichts ersetzt', + 'Nieuw profiel': 'Neues Profil', 'Open eerst een presentatie om afbeeldingen toe te voegen.': - 'Open a presentation before adding images.', - 'Overslaan bij presenteren/exporteren': 'Skip when presenting/exporting', + 'Öffnen Sie zuerst eine Präsentation, um Bilder hinzuzufügen.', + 'Overslaan bij presenteren/exporteren': + 'Beim Präsentieren/Exportieren überspringen', 'PREVIEW': 'VORSCHAU', - 'Paginanummers tonen (rechtsonder)': 'Show page numbers (bottom right)', - 'Pakket geëxporteerd naar:': 'Package exported to:', + 'Paginanummers tonen (rechtsonder)': 'Seitenzahlen anzeigen (unten rechts)', + 'Pakket geëxporteerd naar:': 'Paket exportiert nach:', 'Pas je zoekterm aan of voeg een beschrijving toe.': - 'Adjust your search term or add a description.', + 'Passen Sie Ihren Suchbegriff an oder fügen Sie eine Beschreibung hinzu.', 'Plak de link naar een .ocideck-pakket of een Marp-markdownbestand.': - 'Paste the link to an .ocideck package or a Marp Markdown file.', - 'Preview inklappen': 'Collapse preview', - 'Preview uitklappen': 'Expand preview', - 'Profiel verwijderen': 'Delete profile', - 'Rij verwijderen': 'Remove row', + 'Fügen Sie den Link zu einem .ocideck-Paket oder einer Marp-Markdown-Datei ein.', + 'Preview inklappen': 'Vorschau einklappen', + 'Preview uitklappen': 'Vorschau ausklappen', + 'Profiel verwijderen': 'Profil löschen', + 'Rij verwijderen': 'Zeile entfernen', 'SLIDES': 'SLIDES', - 'Sectieachtergrond': 'Section background', - 'Selecteer een\nafbeelding': 'Select an\nimage', - 'Selectie opheffen': 'Clear selection', + 'Sectieachtergrond': 'Abschnittshintergrund', + 'Selecteer een\nafbeelding': 'Wählen Sie ein\nBild', + 'Selectie opheffen': 'Auswahl aufheben', 'Sleep om de slide-preview breder of smaller te maken': - 'Drag to make the slide preview wider or narrower', + 'Ziehen, um die Folienvorschau breiter oder schmaler zu machen', 'Slide': 'Slide', - 'Slide gekopieerd naar klembord.': 'Slide copied to clipboard.', - 'Slide plakken': 'Paste slide', - 'Slide renderen…': 'Rendering slide…', - 'Slide toevoegen': 'Add slide', + 'Slide gekopieerd naar klembord.': 'Folie in die Zwischenablage kopiert.', + 'Slide plakken': 'Folie einfügen', + 'Slide renderen…': 'Folie wird gerendert…', + 'Slide toevoegen': 'Folie hinzufügen', 'Slides gerenderd.': 'Folien gerendert.', - 'Sluiten (G of Esc)': 'Close (G or Esc)', - 'Sprekersnotities...': 'Speaker notes...', + 'Sluiten (G of Esc)': 'Schließen (G oder Esc)', + 'Sprekersnotities...': 'Sprechernotizen...', 'Standaard laatste slide gebruiken': 'Standardmäßige letzte Folie verwenden', - 'Standaard map voor presentaties': 'Default presentation folder', - 'Standaardprofiel laden': 'Load default profile', + 'Standaard map voor presentaties': 'Standardordner für Präsentationen', + 'Standaardprofiel laden': 'Standardprofil laden', 'TLP-classificatie (Traffic Light Protocol)': - 'TLP classification (Traffic Light Protocol)', - 'Tabel koptekst': 'Table header text', - 'Tabeltekst': 'Table text', - 'Terug naar standaardstijl': 'Back to default style', + 'TLP-Klassifizierung (Traffic Light Protocol)', + 'Tabel koptekst': 'Tabellenkopf', + 'Tabeltekst': 'Tabellentext', + 'Terug naar standaardstijl': 'Zurück zum Standardstil', 'Terugzetten (volledige afbeelding zichtbaar)': - 'Reset (full image visible)', - 'Tijd resetten (R)': 'Reset timer (R)', + 'Zurücksetzen (vollständiges Bild sichtbar)', + 'Tijd resetten (R)': 'Zeit zurücksetzen (R)', 'Tip: druk op Enter binnen een cel voor een nieuwe regel.': - 'Tip: press Enter inside a cell for a new line.', - 'Titelachtergrond': 'Title background', - 'Titeltekst': 'Title text', - 'Toepassen': 'Apply', + 'Tipp: Drücken Sie Enter in einer Zelle für eine neue Zeile.', + 'Titelachtergrond': 'Titelhintergrund', + 'Titeltekst': 'Titeltext', + 'Toepassen': 'Anwenden', 'Tokens: {page}, {total}, {date}, {title}. Footer verschijnt op alle slides behalve titel- en sectieslides, tenzij je hem per slide uitzet.': - 'Tokens: {page}, {total}, {date}, {title}. The footer appears on all slides except title and section slides, unless you disable it per slide.', + 'Tokens: {page}, {total}, {date}, {title}. Die Fußzeile erscheint auf allen Folien außer Titel- und Abschnittsfolien, sofern Sie sie nicht pro Folie deaktivieren.', 'Typ zoektermen om slides uit al je presentaties te vinden.': - 'Type search terms to find slides across your presentations.', - 'Uitgezoomd': 'Zoomed out', + 'Geben Sie Suchbegriffe ein, um Folien aus allen Präsentationen zu finden.', + 'Uitgezoomd': 'Verkleinert', 'Uitzoomen (meer van de foto zichtbaar)': - 'Zoom out (more of the photo visible)', - 'Verwijder afbeelding': 'Remove image', - 'Verwijder logo': 'Remove logo', + 'Verkleinern (mehr vom Foto sichtbar)', + 'Verwijder afbeelding': 'Bild entfernen', + 'Verwijder logo': 'Logo entfernen', 'Verwijderen maakt die slides leeg. Dit kan niet ongedaan worden gemaakt.': - 'Deleting will clear those slides. This cannot be undone.', - 'Volledig zichtbaar (100%)': 'Fully visible (100%)', - 'Vul een titel in': 'Enter a title', - 'Weer tonen': 'Show again', + 'Durch das Löschen werden diese Folien geleert. Dies kann nicht rückgängig gemacht werden.', + 'Volledig zichtbaar (100%)': 'Vollständig sichtbar (100%)', + 'Vul een titel in': 'Geben Sie einen Titel ein', + 'Weer tonen': 'Wieder anzeigen', 'Weer tonen bij presenteren/exporteren': - 'Show again when presenting/exporting', + 'Beim Präsentieren/Exportieren wieder anzeigen', 'Wordt automatisch toegevoegd bij presenteren en exporteren.': 'Wird beim Präsentieren und Exportieren automatisch hinzugefügt.', - 'Zoek in slides…': 'Search in slides…', + 'Zoek in slides…': 'In Folien suchen…', 'Zoek op bestandsnaam, titel of tekst in de slides…': - 'Search by file name, title or slide text…', - 'Zoek op naam of beschrijving…': 'Search by name or description…', + 'Nach Dateiname, Titel oder Text in den Folien suchen…', + 'Zoek op naam of beschrijving…': 'Nach Name oder Beschreibung suchen…', 'Zoek op presentatie, titel of tekst…': - 'Search by presentation, title or text…', + 'Nach Präsentation, Titel oder Text suchen…', 'Zoek slides op tekst, titel, onderschrift, pad…': - 'Search slides by text, title, caption, path…', + 'Folien nach Text, Titel, Bildunterschrift, Pfad suchen…', 'bijv. Vertrouwelijk · {title} · {date}': - 'e.g. Confidential · {title} · {date}', + 'z. B. Vertraulich · {title} · {date}', 'gerenderd.': 'gerendert.', - 'geselecteerd': 'selected', - 'meer treffer(s)': 'more match(es)', - 'paginering aan': 'pagination on', + 'geselecteerd': 'ausgewählt', + 'meer treffer(s)': 'weitere Treffer', + 'paginering aan': 'Seitennummerierung an', 'pijltjes + Enter of klik om te springen': - 'arrows + Enter or click to jump', + 'Pfeiltasten + Enter oder Klick zum Springen', 'presentaties met niet-opgeslagen wijzigingen gevonden van een vorige sessie:': - 'presentations with unsaved changes from a previous session:', + 'Präsentationen mit nicht gespeicherten Änderungen aus einer früheren Sitzung gefunden:', 'renderen…': 'rendern…', - 'resultaat': 'result', - 'resultaten': 'results', + 'resultaat': 'Ergebnis', + 'resultaten': 'Ergebnisse', 'slide': 'slide', - 'slide(s) gekopieerd naar': 'slide(s) copied to', - 'slides geïmporteerd.': 'slides imported.', - 'slides kopiëren naar…': 'slides to copy to…', - 'slides overgeslagen': 'slides skipped', - 'toegevoegd': 'added', - 'treffer(s)': 'match(es)', - 'treffers — verfijn je zoekopdracht': 'matches, refine your search', - 'van de foto zichtbaar': 'of the photo visible', - 'vervangen': 'replaced', - 'verwijderen': 'remove', - 'volledig deck': 'full deck', + 'slide(s) gekopieerd naar': 'Folie(n) kopiert nach', + 'slides geïmporteerd.': 'Folien importiert.', + 'slides kopiëren naar…': 'Folien kopieren nach…', + 'slides overgeslagen': 'Folien übersprungen', + 'toegevoegd': 'hinzugefügt', + 'treffer(s)': 'Treffer', + 'treffers — verfijn je zoekopdracht': 'Treffer — verfeinern Sie Ihre Suche', + 'van de foto zichtbaar': 'vom Foto sichtbar', + 'vervangen': 'ersetzt', + 'verwijderen': 'entfernen', + 'volledig deck': 'vollständiges Deck', 'voorbereiden…': 'vorbereiten…', '↑↓←→ navigeren · Enter kiezen · Dubbelklik selecteert': - '↑↓←→ navigate · Enter chooses · Double-click selects', + '↑↓←→ navigieren · Enter wählen · Doppelklick wählt aus', 'Duplicaten opruimen': 'Duplikate aufräumen', 'Zoek byte-identieke afbeeldingen (md5), voeg tags en opmerkingen samen en verwijder de kopieën': 'Byte-identische Bilder (md5) finden, Tags und Anmerkungen zusammenführen und die Kopien löschen', @@ -3223,217 +3263,222 @@ const _dutchSourceStringAdditions = { 'Aflopend sorteren': 'Trier par ordre décroissant', 'Bij een cirkel worden maximaal de eerste twee reeksen getoond; de labels vormen de segmenten.': 'Les graphiques en secteurs affichent au maximum les deux premières séries ; les libellés forment les segments.', - '# Slide\n\nInhoud hier...': '# Slide\n\nContent here...', - '1 slide geïmporteerd.': '1 slide imported.', - '1 slide kopiëren naar…': 'Copy 1 slide to…', - '1 slide overgeslagen': '1 slide skipped', + '# Slide\n\nInhoud hier...': '# Diapositive\n\nContenu ici...', + '1 slide geïmporteerd.': '1 diapositive importée.', + '1 slide kopiëren naar…': 'Copier 1 diapositive vers…', + '1 slide overgeslagen': '1 diapositive ignorée', 'Accent / bullets': 'Accent / bullets', - 'Achtergrond slides': 'Slide background', + 'Achtergrond slides': 'Arrière-plan des diapositives', 'Afbeelding': 'Image', - 'Afbeelding gekopieerd naar klembord.': 'Image copied to clipboard.', - 'Afbeelding plakken': 'Paste image', - 'Afbeelding plakken uit klembord': 'Paste image from clipboard', + 'Afbeelding gekopieerd naar klembord.': + 'Image copiée dans le presse-papiers.', + 'Afbeelding plakken': 'Coller l\'image', + 'Afbeelding plakken uit klembord': + 'Coller l\'image depuis le presse-papiers', 'Afbeeldingen → nieuwe slides · .md / .ocideck → openen': - 'Images → new slides · .md / .ocideck → open', - 'Afsluiten (Escape)': 'Exit (Escape)', + 'Images → nouvelles diapositives · .md / .ocideck → ouvrir', + 'Afsluiten (Escape)': 'Quitter (Échap)', 'Alle slides zijn overgeslagen — niets om te exporteren.': - 'All slides are skipped, so there is nothing to export.', + 'Toutes les diapositives sont ignorées — rien à exporter.', 'Alle slides zijn overgeslagen — niets om te tonen.': - 'All slides are skipped, so there is nothing to show.', - 'Alles tonen': 'Show all', - 'Audio verwijderen': 'Remove audio', - 'Automatisch doorgaan na': 'Advance automatically after', - 'Bijv. Kwartaalupdate Q4': 'E.g. Q4 update', + 'Toutes les diapositives sont ignorées — rien à afficher.', + 'Alles tonen': 'Tout afficher', + 'Audio verwijderen': 'Supprimer l\'audio', + 'Automatisch doorgaan na': 'Avancer automatiquement après', + 'Bijv. Kwartaalupdate Q4': 'Ex. Mise à jour T4', 'Bullet': 'Puce', 'Caption / bronvermelding (bijv. © Naam Fotograaf)': - 'Caption / credit (e.g. © Photographer Name)', + 'Légende / crédit (ex. © Nom du photographe)', 'Coverflow': 'Coverflow', 'De afbeelding wordt schermvullend als achtergrond getoond met verminderde opaciteit zodat de tekst leesbaar blijft.': - 'The image is shown fullscreen as a background with reduced opacity so the text remains readable.', + 'L\'image est affichée en plein écran comme arrière-plan avec une opacité réduite afin que le texte reste lisible.', 'De snelle bruine vos springt over de luie hond.': - 'The quick brown fox jumps over the lazy dog.', + 'Portez ce vieux whisky au juge blond qui fume.', 'Deze gegevens worden in de markdown opgeslagen en zijn doorzoekbaar bij het openen.': - 'These details are stored in the Markdown and searchable when opening.', + 'Ces données sont enregistrées dans le Markdown et consultables à l\'ouverture.', 'Deze presentatie heeft niet-opgeslagen wijzigingen. Sla de presentatie op voordat het tabblad sluit.': - 'This presentation has unsaved changes. Save it before closing the tab.', + 'Cette présentation comporte des modifications non enregistrées. Enregistrez-la avant de fermer l\'onglet.', 'Deze slide kan geen afbeelding ontvangen. Kies eerst een afbeeldingsslide.': - 'This slide cannot receive an image. Choose an image slide first.', - 'Eerste': 'First', - 'Einde van de presentatie': 'End of presentation', + 'Cette diapositive ne peut pas recevoir d\'image. Choisissez d\'abord une diapositive d\'image.', + 'Eerste': 'Première', + 'Einde van de presentatie': 'Fin de la présentation', 'Er is een presentatie met niet-opgeslagen wijzigingen gevonden van een vorige sessie:': - 'A presentation with unsaved changes was found from a previous session:', - 'Er zijn': 'There are', + 'Une présentation comportant des modifications non enregistrées a été trouvée depuis une session précédente :', + 'Er zijn': 'Il y a', 'Er zijn presentaties met niet-opgeslagen wijzigingen. Sla ze op voordat de app sluit.': - 'There are presentations with unsaved changes. Save them before closing the app.', - 'Export mislukt:': 'Export failed:', - 'Footer tonen op deze slide': 'Show footer on this slide', + 'Des présentations comportent des modifications non enregistrées. Enregistrez-les avant de fermer l\'application.', + 'Export mislukt:': 'Échec de l\'exportation :', + 'Footer tonen op deze slide': + 'Afficher le pied de page sur cette diapositive', 'Gebruik "Bladeren" om afbeeldingen van elke locatie te kiezen.': - 'Use “Browse” to choose images from any location.', + 'Utilisez « Parcourir » pour choisir des images de n\'importe quel emplacement.', 'Geen afbeelding op het klembord gevonden.': - 'No image found on the clipboard.', + 'Aucune image trouvée dans le presse-papiers.', 'Geen ander deck open. Open eerst een ander tabblad.': - 'No other deck is open. Open another tab first.', + 'Aucun autre deck ouvert. Ouvrez d\'abord un autre onglet.', 'Geen andere presentaties (.md) in deze map gevonden.': - 'No other presentations (.md) found in this folder.', - 'Geen notities voor deze slide.': 'No notes for this slide.', + 'Aucune autre présentation (.md) trouvée dans ce dossier.', + 'Geen notities voor deze slide.': 'Aucune note pour cette diapositive.', 'Geen presentaties (.md) in deze map gevonden.': - 'No presentations (.md) found in this folder.', - 'Geen presentaties gevonden voor': 'No presentations found for', - 'Geen resultaten': 'No results', - 'Geen resultaten voor': 'No results for', - 'Geen slides gevonden voor': 'No slides found for', - 'Geen slides met': 'No slides with', - 'Geselecteerd': 'Selected', + 'Aucune présentation (.md) trouvée dans ce dossier.', + 'Geen presentaties gevonden voor': 'Aucune présentation trouvée pour', + 'Geen resultaten': 'Aucun résultat', + 'Geen resultaten voor': 'Aucun résultat pour', + 'Geen slides gevonden voor': 'Aucune diapositive trouvée pour', + 'Geen slides met': 'Aucune diapositive avec', + 'Geselecteerd': 'Sélectionné', 'HTML opent in elke browser zonder internet en rendert codeblokken, wiskunde en mermaid-diagrammen.': 'Le HTML s’ouvre dans n’importe quel navigateur sans internet et rend les blocs de code, les mathématiques et les diagrammes Mermaid.', 'Het bestand wordt permanent van schijf verwijderd. Deze actie kan niet ongedaan worden gemaakt.': - 'The file will be permanently deleted from disk. This action cannot be undone.', - 'Ingezoomd': 'Zoomed in', + 'Le fichier sera définitivement supprimé du disque. Cette action est irréversible.', + 'Ingezoomd': 'Zoom avant', 'Inzoomen (minder van de foto zichtbaar)': - 'Zoom in (less of the photo visible)', - 'Kies een afbeelding': 'Choose an image', + 'Zoom avant (moins de la photo visible)', + 'Kies een afbeelding': 'Choisir une image', 'Kies een map met presentaties om te beginnen.': - 'Choose a folder with presentations to begin.', - 'Kon dit pakket niet importeren.': 'Could not import this package.', - 'Kon niet van scherm wisselen.': 'Could not switch screens.', + 'Choisissez un dossier de présentations pour commencer.', + 'Kon dit pakket niet importeren.': 'Impossible d\'importer ce paquet.', + 'Kon niet van scherm wisselen.': 'Impossible de changer d\'écran.', 'Kon van deze URL geen presentatie ophalen.': - 'Could not fetch a presentation from this URL.', - 'Kopieer afbeelding naar klembord': 'Copy image to clipboard', - 'Kopiëren mislukt.': 'Copy failed.', - 'Kopiëren naar ander deck': 'Copy to another deck', - 'Kopiëren naar klembord mislukt.': 'Copying to clipboard failed.', - 'Koprij verwijderen': 'Remove header row', - 'Laat los om toe te voegen': 'Release to add', + 'Impossible de récupérer une présentation depuis cette URL.', + 'Kopieer afbeelding naar klembord': + 'Copier l\'image dans le presse-papiers', + 'Kopiëren mislukt.': 'Échec de la copie.', + 'Kopiëren naar ander deck': 'Copier vers un autre deck', + 'Kopiëren naar klembord mislukt.': + 'Échec de la copie dans le presse-papiers.', + 'Koprij verwijderen': 'Supprimer la ligne d\'en-tête', + 'Laat los om toe te voegen': 'Relâchez pour ajouter', 'Laatste slide': 'Diapositive finale', 'Let op: deze afbeelding wordt nog gebruikt in': - 'Warning: this image is still used in', - 'Logo kiezen': 'Choose logo', + 'Attention : cette image est encore utilisée dans', + 'Logo kiezen': 'Choisir un logo', 'Logo px': 'Logo px', - 'Logo tonen op deze slide': 'Show logo on this slide', - 'Map met presentaties kiezen': 'Choose presentation folder', - 'Map voor exports': 'Export folder', + 'Logo tonen op deze slide': 'Afficher le logo sur cette diapositive', + 'Map met presentaties kiezen': 'Choisir le dossier des présentations', + 'Map voor exports': 'Dossier des exports', 'Markdown kon niet worden verwerkt. Controleer de syntax.': - 'Markdown could not be processed. Check the syntax.', + 'Le Markdown n\'a pas pu être traité. Vérifiez la syntaxe.', 'Markdown modus — bewerk de volledige presentatie als Marp Markdown': - 'Markdown mode — edit the full presentation as Marp Markdown', + 'Mode Markdown — modifiez la présentation entière en Markdown Marp', 'Markdown voor laatste slide': 'Markdown pour la diapositive finale', - 'Naam van het stijlprofiel': 'Name of the style profile', - 'Niet-opgeslagen werk herstellen?': 'Restore unsaved work?', - 'Niet-opgeslagen wijzigingen': 'Unsaved changes', - 'Niets vervangen': 'Nothing replaced', - 'Nieuw profiel': 'New profile', + 'Naam van het stijlprofiel': 'Nom du profil de style', + 'Niet-opgeslagen werk herstellen?': 'Restaurer le travail non enregistré ?', + 'Niet-opgeslagen wijzigingen': 'Modifications non enregistrées', + 'Niets vervangen': 'Rien remplacé', + 'Nieuw profiel': 'Nouveau profil', 'Open eerst een presentatie om afbeeldingen toe te voegen.': - 'Open a presentation before adding images.', - 'Overslaan bij presenteren/exporteren': 'Skip when presenting/exporting', + 'Ouvrez d\'abord une présentation pour ajouter des images.', + 'Overslaan bij presenteren/exporteren': + 'Ignorer lors de la présentation/l\'exportation', 'PREVIEW': 'APERÇU', - 'Paginanummers tonen (rechtsonder)': 'Show page numbers (bottom right)', - 'Pakket geëxporteerd naar:': 'Package exported to:', + 'Paginanummers tonen (rechtsonder)': + 'Afficher les numéros de page (en bas à droite)', + 'Pakket geëxporteerd naar:': 'Paquet exporté vers :', 'Pas je zoekterm aan of voeg een beschrijving toe.': - 'Adjust your search term or add a description.', + 'Ajustez votre terme de recherche ou ajoutez une description.', 'Plak de link naar een .ocideck-pakket of een Marp-markdownbestand.': - 'Paste the link to an .ocideck package or a Marp Markdown file.', - 'Preview inklappen': 'Collapse preview', - 'Preview uitklappen': 'Expand preview', - 'Profiel verwijderen': 'Delete profile', - 'Rij verwijderen': 'Remove row', + 'Collez le lien vers un paquet .ocideck ou un fichier Markdown Marp.', + 'Preview inklappen': 'Réduire l\'aperçu', + 'Preview uitklappen': 'Développer l\'aperçu', + 'Profiel verwijderen': 'Supprimer le profil', + 'Rij verwijderen': 'Supprimer la ligne', 'SLIDES': 'SLIDES', - 'Sectieachtergrond': 'Section background', - 'Selecteer een\nafbeelding': 'Select an\nimage', - 'Selectie opheffen': 'Clear selection', + 'Sectieachtergrond': 'Arrière-plan de section', + 'Selecteer een\nafbeelding': 'Sélectionnez une\nimage', + 'Selectie opheffen': 'Effacer la sélection', 'Sleep om de slide-preview breder of smaller te maken': - 'Drag to make the slide preview wider or narrower', + 'Faites glisser pour élargir ou rétrécir l\'aperçu de la diapositive', 'Slide': 'Slide', - 'Slide gekopieerd naar klembord.': 'Slide copied to clipboard.', - 'Slide plakken': 'Paste slide', - 'Slide renderen…': 'Rendering slide…', - 'Slide toevoegen': 'Add slide', + 'Slide gekopieerd naar klembord.': + 'Diapositive copiée dans le presse-papiers.', + 'Slide plakken': 'Coller la diapositive', + 'Slide renderen…': 'Rendu de la diapositive…', + 'Slide toevoegen': 'Ajouter une diapositive', 'Slides gerenderd.': 'Diapositives rendues.', - 'Sluiten (G of Esc)': 'Close (G or Esc)', - 'Sprekersnotities...': 'Speaker notes...', + 'Sluiten (G of Esc)': 'Fermer (G ou Échap)', + 'Sprekersnotities...': 'Notes de l\'orateur...', 'Standaard laatste slide gebruiken': 'Utiliser la diapositive finale par défaut', - 'Standaard map voor presentaties': 'Default presentation folder', - 'Standaardprofiel laden': 'Load default profile', + 'Standaard map voor presentaties': 'Dossier par défaut des présentations', + 'Standaardprofiel laden': 'Charger le profil par défaut', 'TLP-classificatie (Traffic Light Protocol)': - 'TLP classification (Traffic Light Protocol)', - 'Tabel koptekst': 'Table header text', - 'Tabeltekst': 'Table text', - 'Terug naar standaardstijl': 'Back to default style', + 'Classification TLP (Traffic Light Protocol)', + 'Tabel koptekst': 'En-tête du tableau', + 'Tabeltekst': 'Texte du tableau', + 'Terug naar standaardstijl': 'Revenir au style par défaut', 'Terugzetten (volledige afbeelding zichtbaar)': - 'Reset (full image visible)', - 'Tijd resetten (R)': 'Reset timer (R)', + 'Réinitialiser (image entière visible)', + 'Tijd resetten (R)': 'Réinitialiser le minuteur (R)', 'Tip: druk op Enter binnen een cel voor een nieuwe regel.': - 'Tip: press Enter inside a cell for a new line.', - 'Titelachtergrond': 'Title background', - 'Titeltekst': 'Title text', - 'Toepassen': 'Apply', + 'Astuce : appuyez sur Entrée dans une cellule pour une nouvelle ligne.', + 'Titelachtergrond': 'Arrière-plan du titre', + 'Titeltekst': 'Texte du titre', + 'Toepassen': 'Appliquer', 'Tokens: {page}, {total}, {date}, {title}. Footer verschijnt op alle slides behalve titel- en sectieslides, tenzij je hem per slide uitzet.': - 'Tokens: {page}, {total}, {date}, {title}. The footer appears on all slides except title and section slides, unless you disable it per slide.', + 'Jetons : {page}, {total}, {date}, {title}. Le pied de page apparaît sur toutes les diapositives sauf les diapositives de titre et de section, sauf si vous le désactivez par diapositive.', 'Typ zoektermen om slides uit al je presentaties te vinden.': - 'Type search terms to find slides across your presentations.', - 'Uitgezoomd': 'Zoomed out', + 'Saisissez des termes de recherche pour trouver des diapositives dans toutes vos présentations.', + 'Uitgezoomd': 'Zoom arrière', 'Uitzoomen (meer van de foto zichtbaar)': - 'Zoom out (more of the photo visible)', - 'Verwijder afbeelding': 'Remove image', - 'Verwijder logo': 'Remove logo', + 'Zoom arrière (plus de la photo visible)', + 'Verwijder afbeelding': 'Supprimer l\'image', + 'Verwijder logo': 'Supprimer le logo', 'Verwijderen maakt die slides leeg. Dit kan niet ongedaan worden gemaakt.': - 'Deleting will clear those slides. This cannot be undone.', - 'Volledig zichtbaar (100%)': 'Fully visible (100%)', - 'Vul een titel in': 'Enter a title', - 'Weer tonen': 'Show again', + 'La suppression videra ces diapositives. Cette action est irréversible.', + 'Volledig zichtbaar (100%)': 'Entièrement visible (100%)', + 'Vul een titel in': 'Saisissez un titre', + 'Weer tonen': 'Afficher à nouveau', 'Weer tonen bij presenteren/exporteren': - 'Show again when presenting/exporting', + 'Afficher à nouveau lors de la présentation/l\'exportation', 'Wordt automatisch toegevoegd bij presenteren en exporteren.': 'Ajoutée automatiquement lors de la présentation et de l’exportation.', - 'Zoek in slides…': 'Search in slides…', + 'Zoek in slides…': 'Rechercher dans les diapositives…', 'Zoek op bestandsnaam, titel of tekst in de slides…': - 'Search by file name, title or slide text…', - 'Zoek op naam of beschrijving…': 'Search by name or description…', + 'Rechercher par nom de fichier, titre ou texte dans les diapositives…', + 'Zoek op naam of beschrijving…': 'Rechercher par nom ou description…', 'Zoek op presentatie, titel of tekst…': - 'Search by presentation, title or text…', + 'Rechercher par présentation, titre ou texte…', 'Zoek slides op tekst, titel, onderschrift, pad…': - 'Search slides by text, title, caption, path…', + 'Rechercher des diapositives par texte, titre, légende, chemin…', 'bijv. Vertrouwelijk · {title} · {date}': - 'e.g. Confidential · {title} · {date}', + 'ex. Confidentiel · {title} · {date}', 'gerenderd.': 'rendue.', - 'geselecteerd': 'selected', - 'meer treffer(s)': 'more match(es)', - 'paginering aan': 'pagination on', + 'geselecteerd': 'sélectionné', + 'meer treffer(s)': 'résultat(s) de plus', + 'paginering aan': 'pagination activée', 'pijltjes + Enter of klik om te springen': - 'arrows + Enter or click to jump', + 'flèches + Entrée ou clic pour aller à', 'presentaties met niet-opgeslagen wijzigingen gevonden van een vorige sessie:': - 'presentations with unsaved changes from a previous session:', + 'présentations comportant des modifications non enregistrées trouvées depuis une session précédente :', 'renderen…': 'rendu…', - 'resultaat': 'result', - 'resultaten': 'results', + 'resultaat': 'résultat', + 'resultaten': 'résultats', 'slide': 'slide', - 'slide(s) gekopieerd naar': 'slide(s) copied to', - 'slides geïmporteerd.': 'slides imported.', - 'slides kopiëren naar…': 'slides to copy to…', - 'slides overgeslagen': 'slides skipped', - 'toegevoegd': 'added', - 'treffer(s)': 'match(es)', - 'treffers — verfijn je zoekopdracht': 'matches, refine your search', - 'van de foto zichtbaar': 'of the photo visible', - 'vervangen': 'replaced', - 'verwijderen': 'remove', - 'volledig deck': 'full deck', + 'slide(s) gekopieerd naar': 'diapositive(s) copiée(s) vers', + 'slides geïmporteerd.': 'diapositives importées.', + 'slides kopiëren naar…': 'diapositives à copier vers…', + 'slides overgeslagen': 'diapositives ignorées', + 'toegevoegd': 'ajouté', + 'treffer(s)': 'résultat(s)', + 'treffers — verfijn je zoekopdracht': 'résultats — affinez votre recherche', + 'van de foto zichtbaar': 'de la photo visible', + 'vervangen': 'remplacé', + 'verwijderen': 'supprimer', + 'volledig deck': 'deck complet', 'voorbereiden…': 'préparation…', '↑↓←→ navigeren · Enter kiezen · Dubbelklik selecteert': - '↑↓←→ navigate · Enter chooses · Double-click selects', + '↑↓←→ naviguer · Entrée choisir · Double-clic sélectionne', 'Duplicaten opruimen': 'Nettoyer les doublons', 'Zoek byte-identieke afbeeldingen (md5), voeg tags en opmerkingen samen en verwijder de kopieën': 'Trouver les images identiques octet par octet (md5), fusionner tags et remarques et supprimer les copies', - 'Geen dubbele afbeeldingen gevonden.': - 'Aucune image en double trouvée.', + 'Geen dubbele afbeeldingen gevonden.': 'Aucune image en double trouvée.', 'Dubbele afbeeldingen opruimen?': 'Nettoyer les images en double ?', 'Van elke groep blijft één bestand staan. Tags en opmerkingen worden samengevoegd en slides die een kopie gebruiken verwijzen daarna naar het behouden bestand — ook in presentaties die nu niet geopend zijn.': 'Un seul fichier par groupe est conservé. Les tags et remarques sont fusionnés et les diapositives utilisant une copie pointeront ensuite vers le fichier conservé — y compris dans les présentations qui ne sont pas ouvertes actuellement.', 'Opruimen': 'Nettoyer', - '1 presentatiebestand bijgewerkt.': - '1 fichier de présentation mis à jour.', - 'presentatiebestanden bijgewerkt.': - 'fichiers de présentation mis à jour.', + '1 presentatiebestand bijgewerkt.': '1 fichier de présentation mis à jour.', + 'presentatiebestanden bijgewerkt.': 'fichiers de présentation mis à jour.', 'niet geopend': 'non ouvert', '1 dubbele afbeelding verwijderd.': '1 image en double supprimée.', 'dubbele afbeeldingen verwijderd.': 'images en double supprimées.', @@ -3526,204 +3571,205 @@ const _dutchSourceStringAdditions = { 'Aflopend sorteren': 'Ordenar descendente', 'Bij een cirkel worden maximaal de eerste twee reeksen getoond; de labels vormen de segmenten.': 'Los gráficos circulares muestran como máximo las dos primeras series; las etiquetas forman los segmentos.', - '# Slide\n\nInhoud hier...': '# Slide\n\nContent here...', - '1 slide geïmporteerd.': '1 slide imported.', - '1 slide kopiëren naar…': 'Copy 1 slide to…', - '1 slide overgeslagen': '1 slide skipped', + '# Slide\n\nInhoud hier...': '# Diapositiva\n\nContenido aquí...', + '1 slide geïmporteerd.': '1 diapositiva importada.', + '1 slide kopiëren naar…': 'Copiar 1 diapositiva a…', + '1 slide overgeslagen': '1 diapositiva omitida', 'Accent / bullets': 'Accent / bullets', - 'Achtergrond slides': 'Slide background', + 'Achtergrond slides': 'Fondo de diapositivas', 'Afbeelding': 'Imagen', - 'Afbeelding gekopieerd naar klembord.': 'Image copied to clipboard.', - 'Afbeelding plakken': 'Paste image', - 'Afbeelding plakken uit klembord': 'Paste image from clipboard', + 'Afbeelding gekopieerd naar klembord.': 'Imagen copiada al portapapeles.', + 'Afbeelding plakken': 'Pegar imagen', + 'Afbeelding plakken uit klembord': 'Pegar imagen desde el portapapeles', 'Afbeeldingen → nieuwe slides · .md / .ocideck → openen': - 'Images → new slides · .md / .ocideck → open', - 'Afsluiten (Escape)': 'Exit (Escape)', + 'Imágenes → diapositivas nuevas · .md / .ocideck → abrir', + 'Afsluiten (Escape)': 'Salir (Escape)', 'Alle slides zijn overgeslagen — niets om te exporteren.': - 'All slides are skipped, so there is nothing to export.', + 'Se han omitido todas las diapositivas: no hay nada que exportar.', 'Alle slides zijn overgeslagen — niets om te tonen.': - 'All slides are skipped, so there is nothing to show.', - 'Alles tonen': 'Show all', - 'Audio verwijderen': 'Remove audio', - 'Automatisch doorgaan na': 'Advance automatically after', - 'Bijv. Kwartaalupdate Q4': 'E.g. Q4 update', + 'Se han omitido todas las diapositivas: no hay nada que mostrar.', + 'Alles tonen': 'Mostrar todo', + 'Audio verwijderen': 'Quitar audio', + 'Automatisch doorgaan na': 'Avanzar automáticamente tras', + 'Bijv. Kwartaalupdate Q4': 'P. ej. Actualización del Q4', 'Bullet': 'Viñeta', 'Caption / bronvermelding (bijv. © Naam Fotograaf)': - 'Caption / credit (e.g. © Photographer Name)', + 'Pie de foto / crédito (p. ej. © Nombre del fotógrafo)', 'Coverflow': 'Coverflow', 'De afbeelding wordt schermvullend als achtergrond getoond met verminderde opaciteit zodat de tekst leesbaar blijft.': - 'The image is shown fullscreen as a background with reduced opacity so the text remains readable.', + 'La imagen se muestra a pantalla completa como fondo con opacidad reducida para que el texto siga siendo legible.', 'De snelle bruine vos springt over de luie hond.': - 'The quick brown fox jumps over the lazy dog.', + 'El veloz murciélago hindú comía feliz cardillo y kiwi.', 'Deze gegevens worden in de markdown opgeslagen en zijn doorzoekbaar bij het openen.': - 'These details are stored in the Markdown and searchable when opening.', + 'Estos datos se guardan en el Markdown y se pueden buscar al abrir.', 'Deze presentatie heeft niet-opgeslagen wijzigingen. Sla de presentatie op voordat het tabblad sluit.': - 'This presentation has unsaved changes. Save it before closing the tab.', + 'Esta presentación tiene cambios sin guardar. Guárdala antes de cerrar la pestaña.', 'Deze slide kan geen afbeelding ontvangen. Kies eerst een afbeeldingsslide.': - 'This slide cannot receive an image. Choose an image slide first.', - 'Eerste': 'First', - 'Einde van de presentatie': 'End of presentation', + 'Esta diapositiva no puede recibir una imagen. Elige primero una diapositiva de imagen.', + 'Eerste': 'Primera', + 'Einde van de presentatie': 'Fin de la presentación', 'Er is een presentatie met niet-opgeslagen wijzigingen gevonden van een vorige sessie:': - 'A presentation with unsaved changes was found from a previous session:', - 'Er zijn': 'There are', + 'Se encontró una presentación con cambios sin guardar de una sesión anterior:', + 'Er zijn': 'Hay', 'Er zijn presentaties met niet-opgeslagen wijzigingen. Sla ze op voordat de app sluit.': - 'There are presentations with unsaved changes. Save them before closing the app.', - 'Export mislukt:': 'Export failed:', - 'Footer tonen op deze slide': 'Show footer on this slide', + 'Hay presentaciones con cambios sin guardar. Guárdalas antes de cerrar la aplicación.', + 'Export mislukt:': 'Error al exportar:', + 'Footer tonen op deze slide': 'Mostrar pie de página en esta diapositiva', 'Gebruik "Bladeren" om afbeeldingen van elke locatie te kiezen.': - 'Use “Browse” to choose images from any location.', + 'Usa "Examinar" para elegir imágenes de cualquier ubicación.', 'Geen afbeelding op het klembord gevonden.': - 'No image found on the clipboard.', + 'No se encontró ninguna imagen en el portapapeles.', 'Geen ander deck open. Open eerst een ander tabblad.': - 'No other deck is open. Open another tab first.', + 'No hay otra presentación abierta. Abre primero otra pestaña.', 'Geen andere presentaties (.md) in deze map gevonden.': - 'No other presentations (.md) found in this folder.', - 'Geen notities voor deze slide.': 'No notes for this slide.', + 'No se encontraron otras presentaciones (.md) en esta carpeta.', + 'Geen notities voor deze slide.': 'No hay notas para esta diapositiva.', 'Geen presentaties (.md) in deze map gevonden.': - 'No presentations (.md) found in this folder.', - 'Geen presentaties gevonden voor': 'No presentations found for', - 'Geen resultaten': 'No results', - 'Geen resultaten voor': 'No results for', - 'Geen slides gevonden voor': 'No slides found for', - 'Geen slides met': 'No slides with', - 'Geselecteerd': 'Selected', + 'No se encontraron presentaciones (.md) en esta carpeta.', + 'Geen presentaties gevonden voor': 'No se encontraron presentaciones para', + 'Geen resultaten': 'Sin resultados', + 'Geen resultaten voor': 'Sin resultados para', + 'Geen slides gevonden voor': 'No se encontraron diapositivas para', + 'Geen slides met': 'No hay diapositivas con', + 'Geselecteerd': 'Seleccionado', 'HTML opent in elke browser zonder internet en rendert codeblokken, wiskunde en mermaid-diagrammen.': 'El HTML se abre en cualquier navegador sin internet y renderiza bloques de código, matemáticas y diagramas Mermaid.', 'Het bestand wordt permanent van schijf verwijderd. Deze actie kan niet ongedaan worden gemaakt.': - 'The file will be permanently deleted from disk. This action cannot be undone.', - 'Ingezoomd': 'Zoomed in', + 'El archivo se eliminará permanentemente del disco. Esta acción no se puede deshacer.', + 'Ingezoomd': 'Ampliado', 'Inzoomen (minder van de foto zichtbaar)': - 'Zoom in (less of the photo visible)', - 'Kies een afbeelding': 'Choose an image', + 'Acercar (se ve menos de la foto)', + 'Kies een afbeelding': 'Elige una imagen', 'Kies een map met presentaties om te beginnen.': - 'Choose a folder with presentations to begin.', - 'Kon dit pakket niet importeren.': 'Could not import this package.', - 'Kon niet van scherm wisselen.': 'Could not switch screens.', + 'Elige una carpeta con presentaciones para empezar.', + 'Kon dit pakket niet importeren.': 'No se pudo importar este paquete.', + 'Kon niet van scherm wisselen.': 'No se pudo cambiar de pantalla.', 'Kon van deze URL geen presentatie ophalen.': - 'Could not fetch a presentation from this URL.', - 'Kopieer afbeelding naar klembord': 'Copy image to clipboard', - 'Kopiëren mislukt.': 'Copy failed.', - 'Kopiëren naar ander deck': 'Copy to another deck', - 'Kopiëren naar klembord mislukt.': 'Copying to clipboard failed.', - 'Koprij verwijderen': 'Remove header row', - 'Laat los om toe te voegen': 'Release to add', + 'No se pudo obtener ninguna presentación desde esta URL.', + 'Kopieer afbeelding naar klembord': 'Copiar imagen al portapapeles', + 'Kopiëren mislukt.': 'Error al copiar.', + 'Kopiëren naar ander deck': 'Copiar a otra presentación', + 'Kopiëren naar klembord mislukt.': 'Error al copiar al portapapeles.', + 'Koprij verwijderen': 'Quitar fila de encabezado', + 'Laat los om toe te voegen': 'Suelta para añadir', 'Laatste slide': 'Diapositiva final', 'Let op: deze afbeelding wordt nog gebruikt in': - 'Warning: this image is still used in', - 'Logo kiezen': 'Choose logo', + 'Atención: esta imagen aún se usa en', + 'Logo kiezen': 'Elegir logotipo', 'Logo px': 'Logo px', - 'Logo tonen op deze slide': 'Show logo on this slide', - 'Map met presentaties kiezen': 'Choose presentation folder', - 'Map voor exports': 'Export folder', + 'Logo tonen op deze slide': 'Mostrar logotipo en esta diapositiva', + 'Map met presentaties kiezen': 'Elegir carpeta de presentaciones', + 'Map voor exports': 'Carpeta de exportaciones', 'Markdown kon niet worden verwerkt. Controleer de syntax.': - 'Markdown could not be processed. Check the syntax.', + 'No se pudo procesar el Markdown. Comprueba la sintaxis.', 'Markdown modus — bewerk de volledige presentatie als Marp Markdown': - 'Markdown mode — edit the full presentation as Marp Markdown', + 'Modo Markdown: edita toda la presentación como Marp Markdown', 'Markdown voor laatste slide': 'Markdown para la diapositiva final', - 'Naam van het stijlprofiel': 'Name of the style profile', - 'Niet-opgeslagen werk herstellen?': 'Restore unsaved work?', - 'Niet-opgeslagen wijzigingen': 'Unsaved changes', - 'Niets vervangen': 'Nothing replaced', - 'Nieuw profiel': 'New profile', + 'Naam van het stijlprofiel': 'Nombre del perfil de estilo', + 'Niet-opgeslagen werk herstellen?': '¿Restaurar el trabajo sin guardar?', + 'Niet-opgeslagen wijzigingen': 'Cambios sin guardar', + 'Niets vervangen': 'No se reemplazó nada', + 'Nieuw profiel': 'Nuevo perfil', 'Open eerst een presentatie om afbeeldingen toe te voegen.': - 'Open a presentation before adding images.', - 'Overslaan bij presenteren/exporteren': 'Skip when presenting/exporting', + 'Abre primero una presentación para añadir imágenes.', + 'Overslaan bij presenteren/exporteren': 'Omitir al presentar/exportar', 'PREVIEW': 'VISTA PREVIA', - 'Paginanummers tonen (rechtsonder)': 'Show page numbers (bottom right)', - 'Pakket geëxporteerd naar:': 'Package exported to:', + 'Paginanummers tonen (rechtsonder)': + 'Mostrar números de página (abajo a la derecha)', + 'Pakket geëxporteerd naar:': 'Paquete exportado a:', 'Pas je zoekterm aan of voeg een beschrijving toe.': - 'Adjust your search term or add a description.', + 'Ajusta el término de búsqueda o añade una descripción.', 'Plak de link naar een .ocideck-pakket of een Marp-markdownbestand.': - 'Paste the link to an .ocideck package or a Marp Markdown file.', - 'Preview inklappen': 'Collapse preview', - 'Preview uitklappen': 'Expand preview', - 'Profiel verwijderen': 'Delete profile', - 'Rij verwijderen': 'Remove row', + 'Pega el enlace a un paquete .ocideck o a un archivo Markdown de Marp.', + 'Preview inklappen': 'Contraer vista previa', + 'Preview uitklappen': 'Expandir vista previa', + 'Profiel verwijderen': 'Eliminar perfil', + 'Rij verwijderen': 'Quitar fila', 'SLIDES': 'SLIDES', - 'Sectieachtergrond': 'Section background', - 'Selecteer een\nafbeelding': 'Select an\nimage', - 'Selectie opheffen': 'Clear selection', + 'Sectieachtergrond': 'Fondo de sección', + 'Selecteer een\nafbeelding': 'Selecciona una\nimagen', + 'Selectie opheffen': 'Borrar selección', 'Sleep om de slide-preview breder of smaller te maken': - 'Drag to make the slide preview wider or narrower', + 'Arrastra para ensanchar o estrechar la vista previa de la diapositiva', 'Slide': 'Slide', - 'Slide gekopieerd naar klembord.': 'Slide copied to clipboard.', - 'Slide plakken': 'Paste slide', - 'Slide renderen…': 'Rendering slide…', - 'Slide toevoegen': 'Add slide', + 'Slide gekopieerd naar klembord.': 'Diapositiva copiada al portapapeles.', + 'Slide plakken': 'Pegar diapositiva', + 'Slide renderen…': 'Renderizando diapositiva…', + 'Slide toevoegen': 'Añadir diapositiva', 'Slides gerenderd.': 'Diapositivas renderizadas.', - 'Sluiten (G of Esc)': 'Close (G or Esc)', - 'Sprekersnotities...': 'Speaker notes...', + 'Sluiten (G of Esc)': 'Cerrar (G o Esc)', + 'Sprekersnotities...': 'Notas del orador...', 'Standaard laatste slide gebruiken': 'Usar diapositiva final predeterminada', - 'Standaard map voor presentaties': 'Default presentation folder', - 'Standaardprofiel laden': 'Load default profile', + 'Standaard map voor presentaties': + 'Carpeta predeterminada de presentaciones', + 'Standaardprofiel laden': 'Cargar perfil predeterminado', 'TLP-classificatie (Traffic Light Protocol)': - 'TLP classification (Traffic Light Protocol)', - 'Tabel koptekst': 'Table header text', - 'Tabeltekst': 'Table text', - 'Terug naar standaardstijl': 'Back to default style', + 'Clasificación TLP (Traffic Light Protocol)', + 'Tabel koptekst': 'Texto del encabezado de tabla', + 'Tabeltekst': 'Texto de tabla', + 'Terug naar standaardstijl': 'Volver al estilo predeterminado', 'Terugzetten (volledige afbeelding zichtbaar)': - 'Reset (full image visible)', - 'Tijd resetten (R)': 'Reset timer (R)', + 'Restablecer (imagen completa visible)', + 'Tijd resetten (R)': 'Restablecer tiempo (R)', 'Tip: druk op Enter binnen een cel voor een nieuwe regel.': - 'Tip: press Enter inside a cell for a new line.', - 'Titelachtergrond': 'Title background', - 'Titeltekst': 'Title text', - 'Toepassen': 'Apply', + 'Consejo: pulsa Enter dentro de una celda para una nueva línea.', + 'Titelachtergrond': 'Fondo de título', + 'Titeltekst': 'Texto del título', + 'Toepassen': 'Aplicar', 'Tokens: {page}, {total}, {date}, {title}. Footer verschijnt op alle slides behalve titel- en sectieslides, tenzij je hem per slide uitzet.': - 'Tokens: {page}, {total}, {date}, {title}. The footer appears on all slides except title and section slides, unless you disable it per slide.', + 'Tokens: {page}, {total}, {date}, {title}. El pie de página aparece en todas las diapositivas salvo las de título y de sección, a menos que lo desactives por diapositiva.', 'Typ zoektermen om slides uit al je presentaties te vinden.': - 'Type search terms to find slides across your presentations.', - 'Uitgezoomd': 'Zoomed out', - 'Uitzoomen (meer van de foto zichtbaar)': - 'Zoom out (more of the photo visible)', - 'Verwijder afbeelding': 'Remove image', - 'Verwijder logo': 'Remove logo', + 'Escribe términos de búsqueda para encontrar diapositivas en todas tus presentaciones.', + 'Uitgezoomd': 'Reducido', + 'Uitzoomen (meer van de foto zichtbaar)': 'Alejar (se ve más de la foto)', + 'Verwijder afbeelding': 'Quitar imagen', + 'Verwijder logo': 'Quitar logotipo', 'Verwijderen maakt die slides leeg. Dit kan niet ongedaan worden gemaakt.': - 'Deleting will clear those slides. This cannot be undone.', - 'Volledig zichtbaar (100%)': 'Fully visible (100%)', - 'Vul een titel in': 'Enter a title', - 'Weer tonen': 'Show again', + 'Al eliminar se vaciarán esas diapositivas. Esto no se puede deshacer.', + 'Volledig zichtbaar (100%)': 'Totalmente visible (100%)', + 'Vul een titel in': 'Introduce un título', + 'Weer tonen': 'Volver a mostrar', 'Weer tonen bij presenteren/exporteren': - 'Show again when presenting/exporting', + 'Volver a mostrar al presentar/exportar', 'Wordt automatisch toegevoegd bij presenteren en exporteren.': 'Se añade automáticamente al presentar y exportar.', - 'Zoek in slides…': 'Search in slides…', + 'Zoek in slides…': 'Buscar en diapositivas…', 'Zoek op bestandsnaam, titel of tekst in de slides…': - 'Search by file name, title or slide text…', - 'Zoek op naam of beschrijving…': 'Search by name or description…', + 'Buscar por nombre de archivo, título o texto en las diapositivas…', + 'Zoek op naam of beschrijving…': 'Buscar por nombre o descripción…', 'Zoek op presentatie, titel of tekst…': - 'Search by presentation, title or text…', + 'Buscar por presentación, título o texto…', 'Zoek slides op tekst, titel, onderschrift, pad…': - 'Search slides by text, title, caption, path…', + 'Buscar diapositivas por texto, título, pie de foto, ruta…', 'bijv. Vertrouwelijk · {title} · {date}': - 'e.g. Confidential · {title} · {date}', + 'p. ej. Confidencial · {title} · {date}', 'gerenderd.': 'renderizada.', - 'geselecteerd': 'selected', - 'meer treffer(s)': 'more match(es)', - 'paginering aan': 'pagination on', + 'geselecteerd': 'seleccionado', + 'meer treffer(s)': 'coincidencia(s) más', + 'paginering aan': 'paginación activada', 'pijltjes + Enter of klik om te springen': - 'arrows + Enter or click to jump', + 'flechas + Enter o clic para saltar', 'presentaties met niet-opgeslagen wijzigingen gevonden van een vorige sessie:': - 'presentations with unsaved changes from a previous session:', + 'presentaciones con cambios sin guardar encontradas de una sesión anterior:', 'renderen…': 'renderizando…', - 'resultaat': 'result', - 'resultaten': 'results', + 'resultaat': 'resultado', + 'resultaten': 'resultados', 'slide': 'slide', - 'slide(s) gekopieerd naar': 'slide(s) copied to', - 'slides geïmporteerd.': 'slides imported.', - 'slides kopiëren naar…': 'slides to copy to…', - 'slides overgeslagen': 'slides skipped', - 'toegevoegd': 'added', - 'treffer(s)': 'match(es)', - 'treffers — verfijn je zoekopdracht': 'matches, refine your search', - 'van de foto zichtbaar': 'of the photo visible', - 'vervangen': 'replaced', - 'verwijderen': 'remove', - 'volledig deck': 'full deck', + 'slide(s) gekopieerd naar': 'diapositiva(s) copiada(s) a', + 'slides geïmporteerd.': 'diapositivas importadas.', + 'slides kopiëren naar…': 'diapositivas a copiar a…', + 'slides overgeslagen': 'diapositivas omitidas', + 'toegevoegd': 'añadido', + 'treffer(s)': 'coincidencia(s)', + 'treffers — verfijn je zoekopdracht': 'coincidencias: afina tu búsqueda', + 'van de foto zichtbaar': 'de la foto visible', + 'vervangen': 'reemplazado', + 'verwijderen': 'quitar', + 'volledig deck': 'presentación completa', 'voorbereiden…': 'preparando…', '↑↓←→ navigeren · Enter kiezen · Dubbelklik selecteert': - '↑↓←→ navigate · Enter chooses · Double-click selects', + '↑↓←→ navegar · Enter elegir · Doble clic selecciona', 'Duplicaten opruimen': 'Limpiar duplicados', 'Zoek byte-identieke afbeeldingen (md5), voeg tags en opmerkingen samen en verwijder de kopieën': 'Buscar imágenes idénticas byte a byte (md5), combinar etiquetas y notas y eliminar las copias', @@ -3827,201 +3873,203 @@ const _dutchSourceStringAdditions = { 'Aflopend sorteren': 'Ôfrinnend sortearje', 'Bij een cirkel worden maximaal de eerste twee reeksen getoond; de labels vormen de segmenten.': 'Sirkeldiagrammen litte maksimaal de earste twa rigen sjen; de labels foarmje de segminten.', - '# Slide\n\nInhoud hier...': '# Slide\n\nContent here...', - '1 slide geïmporteerd.': '1 slide imported.', - '1 slide kopiëren naar…': 'Copy 1 slide to…', - '1 slide overgeslagen': '1 slide skipped', + '# Slide\n\nInhoud hier...': '# Dia\n\nYnhâld hjir...', + '1 slide geïmporteerd.': '1 dia ymportearre.', + '1 slide kopiëren naar…': '1 dia kopiearje nei…', + '1 slide overgeslagen': '1 dia oerslein', 'Accent / bullets': 'Accent / bullets', - 'Achtergrond slides': 'Slide background', + 'Achtergrond slides': 'Eftergrûn dia\'s', 'Afbeelding': 'Ofbylding', - 'Afbeelding gekopieerd naar klembord.': 'Image copied to clipboard.', - 'Afbeelding plakken': 'Paste image', - 'Afbeelding plakken uit klembord': 'Paste image from clipboard', + 'Afbeelding gekopieerd naar klembord.': + 'Ôfbylding kopiearre nei klamboerd.', + 'Afbeelding plakken': 'Ôfbylding plakke', + 'Afbeelding plakken uit klembord': 'Ôfbylding plakke út klamboerd', 'Afbeeldingen → nieuwe slides · .md / .ocideck → openen': - 'Images → new slides · .md / .ocideck → open', - 'Afsluiten (Escape)': 'Exit (Escape)', + 'Ôfbyldingen → nije dia\'s · .md / .ocideck → iepenje', + 'Afsluiten (Escape)': 'Ôfslute (Escape)', 'Alle slides zijn overgeslagen — niets om te exporteren.': - 'All slides are skipped, so there is nothing to export.', + 'Alle dia\'s binne oerslein — neat om te eksportearjen.', 'Alle slides zijn overgeslagen — niets om te tonen.': - 'All slides are skipped, so there is nothing to show.', - 'Alles tonen': 'Show all', - 'Audio verwijderen': 'Remove audio', - 'Automatisch doorgaan na': 'Advance automatically after', - 'Bijv. Kwartaalupdate Q4': 'E.g. Q4 update', + 'Alle dia\'s binne oerslein — neat om sjen te litten.', + 'Alles tonen': 'Alles sjen litte', + 'Audio verwijderen': 'Audio fuortsmite', + 'Automatisch doorgaan na': 'Automatysk fierder nei', + 'Bijv. Kwartaalupdate Q4': 'Bgl. Kwartaalupdate Q4', 'Bullet': 'Puntsje', 'Caption / bronvermelding (bijv. © Naam Fotograaf)': - 'Caption / credit (e.g. © Photographer Name)', + 'Ûnderskrift / boarne (bgl. © Namme Fotograaf)', 'Coverflow': 'Coverflow', 'De afbeelding wordt schermvullend als achtergrond getoond met verminderde opaciteit zodat de tekst leesbaar blijft.': - 'The image is shown fullscreen as a background with reduced opacity so the text remains readable.', + 'De ôfbylding wurdt skermfoljend as eftergrûn toand mei mindere ûntrochsichtigens sadat de tekst lêsber bliuwt.', 'De snelle bruine vos springt over de luie hond.': - 'The quick brown fox jumps over the lazy dog.', + 'De flugge brune foks springt oer de loaie hûn.', 'Deze gegevens worden in de markdown opgeslagen en zijn doorzoekbaar bij het openen.': - 'These details are stored in the Markdown and searchable when opening.', + 'Dizze gegevens wurde yn de markdown opslein en binne trochsykber by it iepenjen.', 'Deze presentatie heeft niet-opgeslagen wijzigingen. Sla de presentatie op voordat het tabblad sluit.': - 'This presentation has unsaved changes. Save it before closing the tab.', + 'Dizze presintaasje hat net-opsleine wizigingen. Bewarje de presintaasje foardat it ljepblêd slút.', 'Deze slide kan geen afbeelding ontvangen. Kies eerst een afbeeldingsslide.': - 'This slide cannot receive an image. Choose an image slide first.', - 'Eerste': 'First', - 'Einde van de presentatie': 'End of presentation', + 'Dizze dia kin gjin ôfbylding ûntfange. Kies earst in ôfbyldingsdia.', + 'Eerste': 'Earste', + 'Einde van de presentatie': 'Ein fan de presintaasje', 'Er is een presentatie met niet-opgeslagen wijzigingen gevonden van een vorige sessie:': - 'A presentation with unsaved changes was found from a previous session:', - 'Er zijn': 'There are', + 'Der is in presintaasje mei net-opsleine wizigingen fûn fan in foarige sesje:', + 'Er zijn': 'Der binne', 'Er zijn presentaties met niet-opgeslagen wijzigingen. Sla ze op voordat de app sluit.': - 'There are presentations with unsaved changes. Save them before closing the app.', - 'Export mislukt:': 'Export failed:', - 'Footer tonen op deze slide': 'Show footer on this slide', + 'Der binne presintaasjes mei net-opsleine wizigingen. Bewarje se foardat de app slút.', + 'Export mislukt:': 'Eksport mislearre:', + 'Footer tonen op deze slide': 'Foettekst sjen litte op dizze dia', 'Gebruik "Bladeren" om afbeeldingen van elke locatie te kiezen.': - 'Use “Browse” to choose images from any location.', + 'Brûk "Blêdzje" om ôfbyldingen fan elke lokaasje te kiezen.', 'Geen afbeelding op het klembord gevonden.': - 'No image found on the clipboard.', + 'Gjin ôfbylding op it klamboerd fûn.', 'Geen ander deck open. Open eerst een ander tabblad.': - 'No other deck is open. Open another tab first.', + 'Gjin oar deck iepen. Iepenje earst in oar ljepblêd.', 'Geen andere presentaties (.md) in deze map gevonden.': - 'No other presentations (.md) found in this folder.', + 'Gjin oare presintaasjes (.md) yn dizze map fûn.', 'Geen presentaties (.md) in deze map gevonden.': - 'No presentations (.md) found in this folder.', - 'Geen presentaties gevonden voor': 'No presentations found for', - 'Geen resultaten': 'No results', - 'Geen resultaten voor': 'No results for', - 'Geen slides gevonden voor': 'No slides found for', - 'Geen slides met': 'No slides with', - 'Geselecteerd': 'Selected', + 'Gjin presintaasjes (.md) yn dizze map fûn.', + 'Geen presentaties gevonden voor': 'Gjin presintaasjes fûn foar', + 'Geen resultaten': 'Gjin resultaten', + 'Geen resultaten voor': 'Gjin resultaten foar', + 'Geen slides gevonden voor': 'Gjin dia\'s fûn foar', + 'Geen slides met': 'Gjin dia\'s mei', + 'Geselecteerd': 'Selektearre', 'HTML opent in elke browser zonder internet en rendert codeblokken, wiskunde en mermaid-diagrammen.': 'HTML iepenet yn elke browser sûnder ynternet en rendert koadeblokken, wiskunde en Mermaid-diagrammen.', 'Het bestand wordt permanent van schijf verwijderd. Deze actie kan niet ongedaan worden gemaakt.': - 'The file will be permanently deleted from disk. This action cannot be undone.', - 'Ingezoomd': 'Zoomed in', + 'It bestân wurdt permanint fan skiif fuortsmiten. Dizze aksje kin net ûngedien makke wurde.', + 'Ingezoomd': 'Ynzoomd', 'Inzoomen (minder van de foto zichtbaar)': - 'Zoom in (less of the photo visible)', - 'Kies een afbeelding': 'Choose an image', + 'Ynzoome (minder fan de foto sichtber)', + 'Kies een afbeelding': 'Kies in ôfbylding', 'Kies een map met presentaties om te beginnen.': - 'Choose a folder with presentations to begin.', - 'Kon dit pakket niet importeren.': 'Could not import this package.', + 'Kies in map mei presintaasjes om te begjinnen.', + 'Kon dit pakket niet importeren.': 'Koe dit pakket net ymportearje.', 'Kon van deze URL geen presentatie ophalen.': - 'Could not fetch a presentation from this URL.', - 'Kopieer afbeelding naar klembord': 'Copy image to clipboard', - 'Kopiëren mislukt.': 'Copy failed.', - 'Kopiëren naar ander deck': 'Copy to another deck', - 'Kopiëren naar klembord mislukt.': 'Copying to clipboard failed.', - 'Koprij verwijderen': 'Remove header row', - 'Laat los om toe te voegen': 'Release to add', + 'Koe fan dizze URL gjin presintaasje ophelje.', + 'Kopieer afbeelding naar klembord': 'Kopiearje ôfbylding nei klamboerd', + 'Kopiëren mislukt.': 'Kopiearjen mislearre.', + 'Kopiëren naar ander deck': 'Kopiearje nei oar deck', + 'Kopiëren naar klembord mislukt.': 'Kopiearjen nei klamboerd mislearre.', + 'Koprij verwijderen': 'Koprige fuortsmite', + 'Laat los om toe te voegen': 'Lit los om ta te foegjen', 'Laatste slide': 'Lêste slide', 'Let op: deze afbeelding wordt nog gebruikt in': - 'Warning: this image is still used in', - 'Logo kiezen': 'Choose logo', + 'Tink derom: dizze ôfbylding wurdt noch brûkt yn', + 'Logo kiezen': 'Logo kieze', 'Logo px': 'Logo px', - 'Logo tonen op deze slide': 'Show logo on this slide', - 'Map met presentaties kiezen': 'Choose presentation folder', - 'Map voor exports': 'Export folder', + 'Logo tonen op deze slide': 'Logo sjen litte op dizze dia', + 'Map met presentaties kiezen': 'Map mei presintaasjes kieze', + 'Map voor exports': 'Map foar eksports', 'Markdown kon niet worden verwerkt. Controleer de syntax.': - 'Markdown could not be processed. Check the syntax.', + 'Markdown koe net ferwurke wurde. Kontrolearje de syntaks.', 'Markdown modus — bewerk de volledige presentatie als Marp Markdown': - 'Markdown mode — edit the full presentation as Marp Markdown', + 'Markdown-modus — bewurkje de folsleine presintaasje as Marp Markdown', 'Markdown voor laatste slide': 'Markdown foar de lêste slide', - 'Naam van het stijlprofiel': 'Name of the style profile', - 'Niet-opgeslagen werk herstellen?': 'Restore unsaved work?', - 'Niet-opgeslagen wijzigingen': 'Unsaved changes', - 'Niets vervangen': 'Nothing replaced', - 'Nieuw profiel': 'New profile', + 'Naam van het stijlprofiel': 'Namme fan it stylprofyl', + 'Niet-opgeslagen werk herstellen?': 'Net-opslein wurk werstelle?', + 'Niet-opgeslagen wijzigingen': 'Net-opsleine wizigingen', + 'Niets vervangen': 'Neat ferfongen', + 'Nieuw profiel': 'Nij profyl', 'Open eerst een presentatie om afbeeldingen toe te voegen.': - 'Open a presentation before adding images.', - 'Overslaan bij presenteren/exporteren': 'Skip when presenting/exporting', + 'Iepenje earst in presintaasje om ôfbyldingen ta te foegjen.', + 'Overslaan bij presenteren/exporteren': + 'Oerslaan by presintearjen/eksportearjen', 'PREVIEW': 'FOARBYLD', - 'Paginanummers tonen (rechtsonder)': 'Show page numbers (bottom right)', - 'Pakket geëxporteerd naar:': 'Package exported to:', + 'Paginanummers tonen (rechtsonder)': 'Sidenûmers sjen litte (rjochtsûnder)', + 'Pakket geëxporteerd naar:': 'Pakket eksportearre nei:', 'Pas je zoekterm aan of voeg een beschrijving toe.': - 'Adjust your search term or add a description.', + 'Pas dyn sykterm oan of foegje in beskriuwing ta.', 'Plak de link naar een .ocideck-pakket of een Marp-markdownbestand.': - 'Paste the link to an .ocideck package or a Marp Markdown file.', - 'Preview inklappen': 'Collapse preview', - 'Preview uitklappen': 'Expand preview', - 'Profiel verwijderen': 'Delete profile', - 'Rij verwijderen': 'Remove row', + 'Plak de keppeling nei in .ocideck-pakket of in Marp-markdownbestân.', + 'Preview inklappen': 'Foarbyld ynklappe', + 'Preview uitklappen': 'Foarbyld útklappe', + 'Profiel verwijderen': 'Profyl fuortsmite', + 'Rij verwijderen': 'Rige fuortsmite', 'SLIDES': 'SLIDES', - 'Sectieachtergrond': 'Section background', - 'Selecteer een\nafbeelding': 'Select an\nimage', - 'Selectie opheffen': 'Clear selection', + 'Sectieachtergrond': 'Seksje-eftergrûn', + 'Selecteer een\nafbeelding': 'Selektearje in\nôfbylding', + 'Selectie opheffen': 'Seleksje opheffe', 'Sleep om de slide-preview breder of smaller te maken': - 'Drag to make the slide preview wider or narrower', + 'Sleep om it dia-foarbyld breder of smeller te meitsjen', 'Slide': 'Slide', - 'Slide gekopieerd naar klembord.': 'Slide copied to clipboard.', - 'Slide plakken': 'Paste slide', - 'Slide renderen…': 'Rendering slide…', - 'Slide toevoegen': 'Add slide', + 'Slide gekopieerd naar klembord.': 'Dia kopiearre nei klamboerd.', + 'Slide plakken': 'Dia plakke', + 'Slide renderen…': 'Dia renderje…', + 'Slide toevoegen': 'Dia tafoegje', 'Slides gerenderd.': 'Slides rendere.', - 'Sluiten (G of Esc)': 'Close (G or Esc)', - 'Sprekersnotities...': 'Speaker notes...', + 'Sluiten (G of Esc)': 'Slute (G of Esc)', + 'Sprekersnotities...': 'Sprekkersnotysjes...', 'Standaard laatste slide gebruiken': 'Standert lêste slide brûke', - 'Standaard map voor presentaties': 'Default presentation folder', - 'Standaardprofiel laden': 'Load default profile', + 'Standaard map voor presentaties': 'Standertmap foar presintaasjes', + 'Standaardprofiel laden': 'Standertprofyl lade', 'TLP-classificatie (Traffic Light Protocol)': - 'TLP classification (Traffic Light Protocol)', - 'Tabel koptekst': 'Table header text', - 'Tabeltekst': 'Table text', - 'Terug naar standaardstijl': 'Back to default style', + 'TLP-klassifikaasje (Traffic Light Protocol)', + 'Tabel koptekst': 'Tabel koptekst', + 'Tabeltekst': 'Tabeltekst', + 'Terug naar standaardstijl': 'Werom nei standertstyl', 'Terugzetten (volledige afbeelding zichtbaar)': - 'Reset (full image visible)', - 'Tijd resetten (R)': 'Reset timer (R)', + 'Weromsette (folsleine ôfbylding sichtber)', + 'Tijd resetten (R)': 'Tiid resette (R)', 'Tip: druk op Enter binnen een cel voor een nieuwe regel.': - 'Tip: press Enter inside a cell for a new line.', - 'Titelachtergrond': 'Title background', - 'Titeltekst': 'Title text', - 'Toepassen': 'Apply', + 'Tip: druk op Enter binnen in sel foar in nije rigel.', + 'Titelachtergrond': 'Titeleftergrûn', + 'Titeltekst': 'Titeltekst', + 'Toepassen': 'Tapasse', 'Tokens: {page}, {total}, {date}, {title}. Footer verschijnt op alle slides behalve titel- en sectieslides, tenzij je hem per slide uitzet.': - 'Tokens: {page}, {total}, {date}, {title}. The footer appears on all slides except title and section slides, unless you disable it per slide.', + 'Tokens: {page}, {total}, {date}, {title}. Foettekst ferskynt op alle dia\'s útsein titel- en seksjedia\'s, útsein ast him per dia útsetst.', 'Typ zoektermen om slides uit al je presentaties te vinden.': - 'Type search terms to find slides across your presentations.', - 'Uitgezoomd': 'Zoomed out', + 'Typ syktermen om dia\'s út al dyn presintaasjes te finen.', + 'Uitgezoomd': 'Útzoomd', 'Uitzoomen (meer van de foto zichtbaar)': - 'Zoom out (more of the photo visible)', - 'Verwijder afbeelding': 'Remove image', - 'Verwijder logo': 'Remove logo', + 'Útzoome (mear fan de foto sichtber)', + 'Verwijder afbeelding': 'Ôfbylding fuortsmite', + 'Verwijder logo': 'Logo fuortsmite', 'Verwijderen maakt die slides leeg. Dit kan niet ongedaan worden gemaakt.': - 'Deleting will clear those slides. This cannot be undone.', - 'Volledig zichtbaar (100%)': 'Fully visible (100%)', - 'Vul een titel in': 'Enter a title', - 'Weer tonen': 'Show again', + 'Fuortsmite makket dy dia\'s leech. Dit kin net ûngedien makke wurde.', + 'Volledig zichtbaar (100%)': 'Folslein sichtber (100%)', + 'Vul een titel in': 'Folje in titel yn', + 'Weer tonen': 'Wer sjen litte', 'Weer tonen bij presenteren/exporteren': - 'Show again when presenting/exporting', + 'Wer sjen litte by presintearjen/eksportearjen', 'Wordt automatisch toegevoegd bij presenteren en exporteren.': 'Wurdt automatysk tafoege by presintearjen en eksportearjen.', - 'Zoek in slides…': 'Search in slides…', + 'Zoek in slides…': 'Sykje yn dia\'s…', 'Zoek op bestandsnaam, titel of tekst in de slides…': - 'Search by file name, title or slide text…', - 'Zoek op naam of beschrijving…': 'Search by name or description…', + 'Sykje op bestânsnamme, titel of tekst yn de dia\'s…', + 'Zoek op naam of beschrijving…': 'Sykje op namme of beskriuwing…', 'Zoek op presentatie, titel of tekst…': - 'Search by presentation, title or text…', + 'Sykje op presintaasje, titel of tekst…', 'Zoek slides op tekst, titel, onderschrift, pad…': - 'Search slides by text, title, caption, path…', + 'Sykje dia\'s op tekst, titel, ûnderskrift, paad…', 'bijv. Vertrouwelijk · {title} · {date}': - 'e.g. Confidential · {title} · {date}', + 'bgl. Fertroulik · {title} · {date}', 'gerenderd.': 'rendere.', - 'geselecteerd': 'selected', - 'meer treffer(s)': 'more match(es)', - 'paginering aan': 'pagination on', + 'geselecteerd': 'selektearre', + 'meer treffer(s)': 'mear treffer(s)', + 'paginering aan': 'sideringsnûmering oan', 'pijltjes + Enter of klik om te springen': - 'arrows + Enter or click to jump', + 'pylkjes + Enter of klik om te springen', 'presentaties met niet-opgeslagen wijzigingen gevonden van een vorige sessie:': - 'presentations with unsaved changes from a previous session:', + 'presintaasjes mei net-opsleine wizigingen fûn fan in foarige sesje:', 'renderen…': 'rendere…', - 'resultaat': 'result', - 'resultaten': 'results', + 'resultaat': 'resultaat', + 'resultaten': 'resultaten', 'slide': 'slide', - 'slide(s) gekopieerd naar': 'slide(s) copied to', - 'slides geïmporteerd.': 'slides imported.', - 'slides kopiëren naar…': 'slides to copy to…', - 'slides overgeslagen': 'slides skipped', - 'toegevoegd': 'added', - 'treffer(s)': 'match(es)', - 'treffers — verfijn je zoekopdracht': 'matches, refine your search', - 'van de foto zichtbaar': 'of the photo visible', - 'vervangen': 'replaced', - 'verwijderen': 'remove', - 'volledig deck': 'full deck', + 'slide(s) gekopieerd naar': 'dia(\'s) kopiearre nei', + 'slides geïmporteerd.': 'dia\'s ymportearre.', + 'slides kopiëren naar…': 'dia\'s kopiearje nei…', + 'slides overgeslagen': 'dia\'s oerslein', + 'toegevoegd': 'tafoege', + 'treffer(s)': 'treffer(s)', + 'treffers — verfijn je zoekopdracht': 'treffers — ferfynje dyn sykopdracht', + 'van de foto zichtbaar': 'fan de foto sichtber', + 'vervangen': 'ferfongen', + 'verwijderen': 'fuortsmite', + 'volledig deck': 'folslein deck', 'voorbereiden…': 'tariede…', '↑↓←→ navigeren · Enter kiezen · Dubbelklik selecteert': - '↑↓←→ navigate · Enter chooses · Double-click selects', + '↑↓←→ navigearje · Enter kieze · Dûbelklik selektearret', 'Duplicaten opruimen': 'Duplikaten opromje', 'Zoek byte-identieke afbeeldingen (md5), voeg tags en opmerkingen samen en verwijder de kopieën': 'Sykje byte-identike ôfbyldings (md5), foegje tags en opmerkings gear en smyt de kopyen fuort', @@ -4123,201 +4171,202 @@ const _dutchSourceStringAdditions = { 'Aflopend sorteren': 'Ordená bahando', 'Bij een cirkel worden maximaal de eerste twee reeksen getoond; de labels vormen de segmenten.': 'Gráfikonan circular ta mustra máximo e promé dos serienan; e labelnan ta forma e segmentonan.', - '# Slide\n\nInhoud hier...': '# Slide\n\nContent here...', - '1 slide geïmporteerd.': '1 slide imported.', - '1 slide kopiëren naar…': 'Copy 1 slide to…', - '1 slide overgeslagen': '1 slide skipped', + '# Slide\n\nInhoud hier...': '# Lámina\n\nKontenido akinan...', + '1 slide geïmporteerd.': '1 lámina importá.', + '1 slide kopiëren naar…': 'Kopia 1 lámina pa…', + '1 slide overgeslagen': '1 lámina pasá over', 'Accent / bullets': 'Accent / bullets', - 'Achtergrond slides': 'Slide background', + 'Achtergrond slides': 'Fondo di lámina', 'Afbeelding': 'Imágen', - 'Afbeelding gekopieerd naar klembord.': 'Image copied to clipboard.', - 'Afbeelding plakken': 'Paste image', - 'Afbeelding plakken uit klembord': 'Paste image from clipboard', + 'Afbeelding gekopieerd naar klembord.': 'Imágen kopiá pa klembord.', + 'Afbeelding plakken': 'Pega imágen', + 'Afbeelding plakken uit klembord': 'Pega imágen for di klembord', 'Afbeeldingen → nieuwe slides · .md / .ocideck → openen': - 'Images → new slides · .md / .ocideck → open', - 'Afsluiten (Escape)': 'Exit (Escape)', + 'Imágennan → lámina nobo · .md / .ocideck → habri', + 'Afsluiten (Escape)': 'Sera (Escape)', 'Alle slides zijn overgeslagen — niets om te exporteren.': - 'All slides are skipped, so there is nothing to export.', + 'Tur lámina ta pasá over — no tin nada pa eksportá.', 'Alle slides zijn overgeslagen — niets om te tonen.': - 'All slides are skipped, so there is nothing to show.', - 'Alles tonen': 'Show all', - 'Audio verwijderen': 'Remove audio', - 'Automatisch doorgaan na': 'Advance automatically after', - 'Bijv. Kwartaalupdate Q4': 'E.g. Q4 update', + 'Tur lámina ta pasá over — no tin nada pa mustra.', + 'Alles tonen': 'Mustra tur', + 'Audio verwijderen': 'Kita audio', + 'Automatisch doorgaan na': 'Sigui outomátikamente despues di', + 'Bijv. Kwartaalupdate Q4': 'Por ehèmpel Aktualisashon Q4', 'Bullet': 'Punto', 'Caption / bronvermelding (bijv. © Naam Fotograaf)': - 'Caption / credit (e.g. © Photographer Name)', + 'Kaption / kredito di fuente (por ehèmpel © Nòmber Fotógrafo)', 'Coverflow': 'Coverflow', 'De afbeelding wordt schermvullend als achtergrond getoond met verminderde opaciteit zodat de tekst leesbaar blijft.': - 'The image is shown fullscreen as a background with reduced opacity so the text remains readable.', + 'E imágen ta wòrdu mustrá yenando henter pantaya komo fondo ku opasidat reducí pa e teksto keda legibel.', 'De snelle bruine vos springt over de luie hond.': - 'The quick brown fox jumps over the lazy dog.', + 'E zoro brùin lihé ta bula riba e kachó floho.', 'Deze gegevens worden in de markdown opgeslagen en zijn doorzoekbaar bij het openen.': - 'These details are stored in the Markdown and searchable when opening.', + 'E datonan aki ta wòrdu guardá den e markdown i ta buskabel ora di habri.', 'Deze presentatie heeft niet-opgeslagen wijzigingen. Sla de presentatie op voordat het tabblad sluit.': - 'This presentation has unsaved changes. Save it before closing the tab.', + 'E presentashon aki tin kambionan no guardá. Guarda e presentashon promé ku e tab sera.', 'Deze slide kan geen afbeelding ontvangen. Kies eerst een afbeeldingsslide.': - 'This slide cannot receive an image. Choose an image slide first.', - 'Eerste': 'First', - 'Einde van de presentatie': 'End of presentation', + 'E lámina aki no por risibí un imágen. Skohe promé un lámina di imágen.', + 'Eerste': 'Promé', + 'Einde van de presentatie': 'Final di e presentashon', 'Er is een presentatie met niet-opgeslagen wijzigingen gevonden van een vorige sessie:': - 'A presentation with unsaved changes was found from a previous session:', - 'Er zijn': 'There are', + 'A haña un presentashon ku kambionan no guardá for di un seshon anterior:', + 'Er zijn': 'Tin', 'Er zijn presentaties met niet-opgeslagen wijzigingen. Sla ze op voordat de app sluit.': - 'There are presentations with unsaved changes. Save them before closing the app.', - 'Export mislukt:': 'Export failed:', - 'Footer tonen op deze slide': 'Show footer on this slide', + 'Tin presentashonnan ku kambionan no guardá. Guarda nan promé ku e app sera.', + 'Export mislukt:': 'Eksportashon a faya:', + 'Footer tonen op deze slide': 'Mustra footer riba e lámina aki', 'Gebruik "Bladeren" om afbeeldingen van elke locatie te kiezen.': - 'Use “Browse” to choose images from any location.', + 'Usa "Buska" pa skohe imágennan for di kualke lokashon.', 'Geen afbeelding op het klembord gevonden.': - 'No image found on the clipboard.', + 'No a haña ningun imágen riba klembord.', 'Geen ander deck open. Open eerst een ander tabblad.': - 'No other deck is open. Open another tab first.', + 'No tin otro deck habrí. Habri promé un otro tab.', 'Geen andere presentaties (.md) in deze map gevonden.': - 'No other presentations (.md) found in this folder.', + 'No a haña otro presentashon (.md) den e karpeta aki.', 'Geen presentaties (.md) in deze map gevonden.': - 'No presentations (.md) found in this folder.', - 'Geen presentaties gevonden voor': 'No presentations found for', - 'Geen resultaten': 'No results', - 'Geen resultaten voor': 'No results for', - 'Geen slides gevonden voor': 'No slides found for', - 'Geen slides met': 'No slides with', - 'Geselecteerd': 'Selected', + 'No a haña presentashon (.md) den e karpeta aki.', + 'Geen presentaties gevonden voor': 'No a haña presentashon pa', + 'Geen resultaten': 'No tin resultado', + 'Geen resultaten voor': 'No tin resultado pa', + 'Geen slides gevonden voor': 'No a haña lámina pa', + 'Geen slides met': 'No tin lámina ku', + 'Geselecteerd': 'Seleshoná', 'HTML opent in elke browser zonder internet en rendert codeblokken, wiskunde en mermaid-diagrammen.': 'HTML ta habri den tur browser sin internet i ta render bloknan di kódigo, matemátika i diagramnan Mermaid.', 'Het bestand wordt permanent van schijf verwijderd. Deze actie kan niet ongedaan worden gemaakt.': - 'The file will be permanently deleted from disk. This action cannot be undone.', - 'Ingezoomd': 'Zoomed in', + 'E archivo ta wòrdu kitá permanentemente for di disko. E akshon aki no por wòrdu deshasí.', + 'Ingezoomd': 'Zoom paden', 'Inzoomen (minder van de foto zichtbaar)': - 'Zoom in (less of the photo visible)', - 'Kies een afbeelding': 'Choose an image', + 'Zoom paden (ménos di e potrèt visibel)', + 'Kies een afbeelding': 'Skohe un imágen', 'Kies een map met presentaties om te beginnen.': - 'Choose a folder with presentations to begin.', - 'Kon dit pakket niet importeren.': 'Could not import this package.', + 'Skohe un karpeta ku presentashon pa kuminsá.', + 'Kon dit pakket niet importeren.': 'No por a importá e paket aki.', 'Kon van deze URL geen presentatie ophalen.': - 'Could not fetch a presentation from this URL.', - 'Kopieer afbeelding naar klembord': 'Copy image to clipboard', - 'Kopiëren mislukt.': 'Copy failed.', - 'Kopiëren naar ander deck': 'Copy to another deck', - 'Kopiëren naar klembord mislukt.': 'Copying to clipboard failed.', - 'Koprij verwijderen': 'Remove header row', - 'Laat los om toe te voegen': 'Release to add', + 'No por a haña un presentashon for di e URL aki.', + 'Kopieer afbeelding naar klembord': 'Kopia imágen pa klembord', + 'Kopiëren mislukt.': 'Kopiamentu a faya.', + 'Kopiëren naar ander deck': 'Kopia pa otro deck', + 'Kopiëren naar klembord mislukt.': 'Kopiamentu pa klembord a faya.', + 'Koprij verwijderen': 'Kita fila di enkabesamentu', + 'Laat los om toe te voegen': 'Laga lòs pa agregá', 'Laatste slide': 'Último slide', 'Let op: deze afbeelding wordt nog gebruikt in': - 'Warning: this image is still used in', - 'Logo kiezen': 'Choose logo', + 'Atenshon: e imágen aki ainda ta wòrdu usá den', + 'Logo kiezen': 'Skohe logo', 'Logo px': 'Logo px', - 'Logo tonen op deze slide': 'Show logo on this slide', - 'Map met presentaties kiezen': 'Choose presentation folder', - 'Map voor exports': 'Export folder', + 'Logo tonen op deze slide': 'Mustra logo riba e lámina aki', + 'Map met presentaties kiezen': 'Skohe karpeta ku presentashon', + 'Map voor exports': 'Karpeta pa eksportashon', 'Markdown kon niet worden verwerkt. Controleer de syntax.': - 'Markdown could not be processed. Check the syntax.', + 'No por a prosesá e markdown. Kontrolá e sintaksis.', 'Markdown modus — bewerk de volledige presentatie als Marp Markdown': - 'Markdown mode — edit the full presentation as Marp Markdown', + 'Modo markdown — edita henter e presentashon komo Marp Markdown', 'Markdown voor laatste slide': 'Markdown pa e último slide', - 'Naam van het stijlprofiel': 'Name of the style profile', - 'Niet-opgeslagen werk herstellen?': 'Restore unsaved work?', - 'Niet-opgeslagen wijzigingen': 'Unsaved changes', - 'Niets vervangen': 'Nothing replaced', - 'Nieuw profiel': 'New profile', + 'Naam van het stijlprofiel': 'Nòmber di e profil di stil', + 'Niet-opgeslagen werk herstellen?': 'Restorá trabou no guardá?', + 'Niet-opgeslagen wijzigingen': 'Kambionan no guardá', + 'Niets vervangen': 'No a remplasá nada', + 'Nieuw profiel': 'Profil nobo', 'Open eerst een presentatie om afbeeldingen toe te voegen.': - 'Open a presentation before adding images.', - 'Overslaan bij presenteren/exporteren': 'Skip when presenting/exporting', + 'Habri promé un presentashon pa agregá imágennan.', + 'Overslaan bij presenteren/exporteren': + 'Pasa over ora di presentá/eksportá', 'PREVIEW': 'PREVIEW', - 'Paginanummers tonen (rechtsonder)': 'Show page numbers (bottom right)', - 'Pakket geëxporteerd naar:': 'Package exported to:', + 'Paginanummers tonen (rechtsonder)': + 'Mustra number di página (abou na man drechi)', + 'Pakket geëxporteerd naar:': 'Paket eksportá pa:', 'Pas je zoekterm aan of voeg een beschrijving toe.': - 'Adjust your search term or add a description.', + 'Ahustá bo término di buskeda òf agregá un deskripshon.', 'Plak de link naar een .ocideck-pakket of een Marp-markdownbestand.': - 'Paste the link to an .ocideck package or a Marp Markdown file.', - 'Preview inklappen': 'Collapse preview', - 'Preview uitklappen': 'Expand preview', - 'Profiel verwijderen': 'Delete profile', - 'Rij verwijderen': 'Remove row', + 'Pega e link pa un paket .ocideck òf un archivo markdown di Marp.', + 'Preview inklappen': 'Sera preview', + 'Preview uitklappen': 'Habri preview', + 'Profiel verwijderen': 'Kita profil', + 'Rij verwijderen': 'Kita fila', 'SLIDES': 'SLIDES', - 'Sectieachtergrond': 'Section background', - 'Selecteer een\nafbeelding': 'Select an\nimage', - 'Selectie opheffen': 'Clear selection', + 'Sectieachtergrond': 'Fondo di sekshon', + 'Selecteer een\nafbeelding': 'Selektá un\nimágen', + 'Selectie opheffen': 'Kita selekshon', 'Sleep om de slide-preview breder of smaller te maken': - 'Drag to make the slide preview wider or narrower', + 'Lastra pa hasi e preview di lámina mas hanchu òf mas smal', 'Slide': 'Slide', - 'Slide gekopieerd naar klembord.': 'Slide copied to clipboard.', - 'Slide plakken': 'Paste slide', - 'Slide renderen…': 'Rendering slide…', - 'Slide toevoegen': 'Add slide', + 'Slide gekopieerd naar klembord.': 'Lámina kopiá pa klembord.', + 'Slide plakken': 'Pega lámina', + 'Slide renderen…': 'Renderando lámina…', + 'Slide toevoegen': 'Agregá lámina', 'Slides gerenderd.': 'Slides a wordu render.', - 'Sluiten (G of Esc)': 'Close (G or Esc)', - 'Sprekersnotities...': 'Speaker notes...', + 'Sluiten (G of Esc)': 'Sera (G òf Esc)', + 'Sprekersnotities...': 'Notanan di e presentadó...', 'Standaard laatste slide gebruiken': 'Usa e último slide standard', - 'Standaard map voor presentaties': 'Default presentation folder', - 'Standaardprofiel laden': 'Load default profile', + 'Standaard map voor presentaties': 'Karpeta standard pa presentashon', + 'Standaardprofiel laden': 'Karga profil standard', 'TLP-classificatie (Traffic Light Protocol)': - 'TLP classification (Traffic Light Protocol)', - 'Tabel koptekst': 'Table header text', - 'Tabeltekst': 'Table text', - 'Terug naar standaardstijl': 'Back to default style', + 'Klasifikashon TLP (Traffic Light Protocol)', + 'Tabel koptekst': 'Teksto di enkabesamentu di tabel', + 'Tabeltekst': 'Teksto di tabel', + 'Terug naar standaardstijl': 'Bèk na stil standard', 'Terugzetten (volledige afbeelding zichtbaar)': - 'Reset (full image visible)', - 'Tijd resetten (R)': 'Reset timer (R)', + 'Restorá (henter imágen visibel)', + 'Tijd resetten (R)': 'Resetiá tempu (R)', 'Tip: druk op Enter binnen een cel voor een nieuwe regel.': - 'Tip: press Enter inside a cell for a new line.', - 'Titelachtergrond': 'Title background', - 'Titeltekst': 'Title text', - 'Toepassen': 'Apply', + 'Tip: primi Enter paden di un seld pa un liña nobo.', + 'Titelachtergrond': 'Fondo di título', + 'Titeltekst': 'Teksto di título', + 'Toepassen': 'Apliká', 'Tokens: {page}, {total}, {date}, {title}. Footer verschijnt op alle slides behalve titel- en sectieslides, tenzij je hem per slide uitzet.': - 'Tokens: {page}, {total}, {date}, {title}. The footer appears on all slides except title and section slides, unless you disable it per slide.', + 'Token: {page}, {total}, {date}, {title}. E footer ta aparesé riba tur lámina ku eksepshon di lámina di título i di sekshon, a ménos ku bo apag\'é pa kada lámina.', 'Typ zoektermen om slides uit al je presentaties te vinden.': - 'Type search terms to find slides across your presentations.', - 'Uitgezoomd': 'Zoomed out', + 'Tek términonan di buskeda pa haña lámina for di tur bo presentashonnan.', + 'Uitgezoomd': 'Zoom afó', 'Uitzoomen (meer van de foto zichtbaar)': - 'Zoom out (more of the photo visible)', - 'Verwijder afbeelding': 'Remove image', - 'Verwijder logo': 'Remove logo', + 'Zoom afó (mas di e potrèt visibel)', + 'Verwijder afbeelding': 'Kita imágen', + 'Verwijder logo': 'Kita logo', 'Verwijderen maakt die slides leeg. Dit kan niet ongedaan worden gemaakt.': - 'Deleting will clear those slides. This cannot be undone.', - 'Volledig zichtbaar (100%)': 'Fully visible (100%)', - 'Vul een titel in': 'Enter a title', - 'Weer tonen': 'Show again', + 'Kitamentu ta laga e láminanan ei bashí. Esaki no por wòrdu deshasí.', + 'Volledig zichtbaar (100%)': 'Kompletamente visibel (100%)', + 'Vul een titel in': 'Yena un título', + 'Weer tonen': 'Mustra atrobe', 'Weer tonen bij presenteren/exporteren': - 'Show again when presenting/exporting', + 'Mustra atrobe ora di presentá/eksportá', 'Wordt automatisch toegevoegd bij presenteren en exporteren.': 'Ta wordu agregá automáticamente ora di presentá i eksportá.', - 'Zoek in slides…': 'Search in slides…', + 'Zoek in slides…': 'Buska den lámina…', 'Zoek op bestandsnaam, titel of tekst in de slides…': - 'Search by file name, title or slide text…', - 'Zoek op naam of beschrijving…': 'Search by name or description…', + 'Buska pa nòmber di archivo, título òf teksto den e lámina…', + 'Zoek op naam of beschrijving…': 'Buska pa nòmber òf deskripshon…', 'Zoek op presentatie, titel of tekst…': - 'Search by presentation, title or text…', + 'Buska pa presentashon, título òf teksto…', 'Zoek slides op tekst, titel, onderschrift, pad…': - 'Search slides by text, title, caption, path…', + 'Buska lámina pa teksto, título, kaption, kaminda…', 'bijv. Vertrouwelijk · {title} · {date}': - 'e.g. Confidential · {title} · {date}', + 'por ehèmpel Konfidensial · {title} · {date}', 'gerenderd.': 'render.', - 'geselecteerd': 'selected', - 'meer treffer(s)': 'more match(es)', - 'paginering aan': 'pagination on', - 'pijltjes + Enter of klik om te springen': - 'arrows + Enter or click to jump', + 'geselecteerd': 'seleshoná', + 'meer treffer(s)': 'mas resultado(nan)', + 'paginering aan': 'numerashon di página sendí', + 'pijltjes + Enter of klik om te springen': 'flecha + Enter òf klek pa bula', 'presentaties met niet-opgeslagen wijzigingen gevonden van een vorige sessie:': - 'presentations with unsaved changes from a previous session:', + 'presentashonnan ku kambionan no guardá haña for di un seshon anterior:', 'renderen…': 'render…', - 'resultaat': 'result', - 'resultaten': 'results', + 'resultaat': 'resultado', + 'resultaten': 'resultadonan', 'slide': 'slide', - 'slide(s) gekopieerd naar': 'slide(s) copied to', - 'slides geïmporteerd.': 'slides imported.', - 'slides kopiëren naar…': 'slides to copy to…', - 'slides overgeslagen': 'slides skipped', - 'toegevoegd': 'added', - 'treffer(s)': 'match(es)', - 'treffers — verfijn je zoekopdracht': 'matches, refine your search', - 'van de foto zichtbaar': 'of the photo visible', - 'vervangen': 'replaced', - 'verwijderen': 'remove', - 'volledig deck': 'full deck', + 'slide(s) gekopieerd naar': 'lámina(nan) kopiá pa', + 'slides geïmporteerd.': 'lámina importá.', + 'slides kopiëren naar…': 'kopia lámina pa…', + 'slides overgeslagen': 'lámina pasá over', + 'toegevoegd': 'agregá', + 'treffer(s)': 'resultado(nan)', + 'treffers — verfijn je zoekopdracht': 'resultado — refiná bo buskeda', + 'van de foto zichtbaar': 'di e potrèt visibel', + 'vervangen': 'remplasá', + 'verwijderen': 'kita', + 'volledig deck': 'henter deck', 'voorbereiden…': 'preparando…', '↑↓←→ navigeren · Enter kiezen · Dubbelklik selecteert': - '↑↓←→ navigate · Enter chooses · Double-click selects', + '↑↓←→ navegá · Enter skohe · Dòbel klek ta selektá', 'Duplicaten opruimen': 'Limpia duplikadonan', 'Zoek byte-identieke afbeeldingen (md5), voeg tags en opmerkingen samen en verwijder de kopieën': 'Buska imágennan idéntiko byte pa byte (md5), kombiná tag i remarkanan i eliminá e kopianan', @@ -4327,8 +4376,7 @@ const _dutchSourceStringAdditions = { 'Di kada grupo un archivo so ta keda. Tag i remarkanan ta wòrdu kombiná i e slidenan ku ta usa un kopia lo mustra despues riba e archivo ku a keda — tambe den presentashonnan ku no ta habrí awor.', 'Opruimen': 'Limpia', '1 presentatiebestand bijgewerkt.': '1 archivo di presentashon aktualisá.', - 'presentatiebestanden bijgewerkt.': - 'archivonan di presentashon aktualisá.', + 'presentatiebestanden bijgewerkt.': 'archivonan di presentashon aktualisá.', 'niet geopend': 'no habrí', '1 dubbele afbeelding verwijderd.': '1 imágen duplikado eliminá.', 'dubbele afbeeldingen verwijderd.': 'imágennan duplikado eliminá.', @@ -4337,23 +4385,9 @@ const _dutchSourceStringAdditions = { 'Alle afbeeldingen hebben tags.': 'Tur imágen tin tag.', 'Zet het filter uit om alles weer te zien.': 'Paga e filter pa mira tur kos atrobe.', - 'Welkom bij OciDeck': 'Welcome to OciDeck', - 'Privacy en gebruik': 'Privacy and Usage', - 'OciDeck is een lokale desktop-applicatie. Uw presentaties en gegevens worden uitsluitend op uw computer opgeslagen.': - 'OciDeck is a local desktop application. Your presentations and data are stored exclusively on your computer.', - 'De app verzamelt geen persoonlijke gegevens, geen statistieken en geen gebruiksgegevens. Uw privacy is onze prioriteit.': - 'The app collects no personal data, no statistics, and no usage data. Your privacy is our priority.', - 'Alle gegevens die u in OciDeck invoert, blijven op uw lokale systeem en worden niet naar externe servers gestuurd.': - 'All data you enter in OciDeck remains on your local system and is not sent to external servers.', - 'Licentie (EUPL 1.2)': 'License (EUPL 1.2)', - 'Door op "Akkoord gaan" te klikken, accepteert u deze voorwaarden en gaat u akkoord met het gebruik van OciDeck.': - 'By clicking "Agree", you accept these terms and agree to the use of OciDeck.', - 'Volledige licentie online': 'Full license online', - 'Akkoord gaan': 'Agree', - 'Toestemming intrekken': 'Revoke Consent', - 'Toestemming ingetrokken': 'Consent revoked', - 'U moet eerst de privacy- en gebruiksvoorwaarden accepteren voordat u OciDeck kunt gebruiken.': - 'You must accept the privacy and usage terms before you can use OciDeck.', + // Consent/welkom-strings staan correct in het Papiaments in de base-map + // (_dutchSourceStrings['pap']); hier geen Engelse placeholders die die + // overschaduwen. 'Intrekken': 'Retirá', 'Privacy': 'Privasidat', 'Toestemming': 'Konsentimentu', diff --git a/lib/models/chart.dart b/lib/models/chart.dart index f07bcd2..3e2cc9d 100644 --- a/lib/models/chart.dart +++ b/lib/models/chart.dart @@ -1,5 +1,7 @@ import 'dart:convert'; +import '../utils/log.dart'; + /// Directory (relative to the deck) where linked chart CSVs are kept, so the /// data files stay tidily in one place — separate from images/media. const String chartDataDirName = 'data'; @@ -155,7 +157,8 @@ class ChartSpec { ChartSeries.fromJson(Map.from(s as Map)), ], ); - } catch (_) { + } catch (e, s) { + logError('ChartSpec.parse: decode chart JSON block', e, s); return const ChartSpec(); } } diff --git a/lib/services/annotation_codec.dart b/lib/services/annotation_codec.dart index 78fc8cd..b3b024f 100644 --- a/lib/services/annotation_codec.dart +++ b/lib/services/annotation_codec.dart @@ -2,6 +2,7 @@ import 'dart:convert'; import '../models/annotation.dart'; import '../models/slide.dart'; +import '../utils/log.dart'; /// Serializes the annotation layer into a sidecar payload that is fully /// decoupled from the Marp markdown. @@ -96,7 +97,8 @@ class AnnotationCodec { used.add(target); result[slides[target].id] = strokes; } - } catch (_) { + } catch (e, s) { + logError('AnnotationCodec.decode: decode annotation sidecar JSON', e, s); return {}; } return result; diff --git a/lib/services/caption_service.dart b/lib/services/caption_service.dart index 7433140..b00a71e 100644 --- a/lib/services/caption_service.dart +++ b/lib/services/caption_service.dart @@ -3,6 +3,8 @@ import 'dart:io'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:path/path.dart' as p; +import '../utils/log.dart'; + /// Slaat afbeeldingscaptions op als JSON-sidecar in de map van de afbeelding. /// Bestandsnaam: .ocideck_captions.json class CaptionService { @@ -17,7 +19,8 @@ class CaptionService { final data = jsonDecode(await file.readAsString()) as Map; final caption = data[p.basename(resolvedPath)]; return caption is String ? caption : null; - } catch (_) { + } catch (e) { + logWarning('CaptionService.getCaption: read caption sidecar', e); return null; } } @@ -36,7 +39,9 @@ class CaptionService { data = Map.from( jsonDecode(await file.readAsString()) as Map, ); - } catch (_) {} + } catch (e, s) { + logError('CaptionService.saveCaption: parse existing sidecar', e, s); + } } final key = p.basename(resolvedPath); if (caption.trim().isEmpty) { diff --git a/lib/services/description_service.dart b/lib/services/description_service.dart index e0e3809..074df37 100644 --- a/lib/services/description_service.dart +++ b/lib/services/description_service.dart @@ -3,6 +3,8 @@ import 'dart:io'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:path/path.dart' as p; +import '../utils/log.dart'; + /// Stores short, searchable image descriptions as a JSON sidecar in the image's /// own directory. File name: .ocideck_descriptions.json, keyed by base name. /// @@ -19,7 +21,11 @@ class DescriptionService { final data = jsonDecode(await file.readAsString()) as Map; final value = data[p.basename(imagePath)]; return value is String ? value : null; - } catch (_) { + } catch (e) { + logWarning( + 'DescriptionService.getDescription: read description sidecar', + e, + ); return null; } } @@ -33,7 +39,13 @@ class DescriptionService { data = Map.from( jsonDecode(await file.readAsString()) as Map, ); - } catch (_) {} + } catch (e, s) { + logError( + 'DescriptionService.saveDescription: parse existing sidecar', + e, + s, + ); + } } final key = p.basename(imagePath); if (description.trim().isEmpty) { @@ -71,7 +83,9 @@ class DescriptionService { result[p.join(dir, entry.key as String)] = entry.value as String; } } - } catch (_) {} + } catch (e) { + logWarning('DescriptionService.loadFor: read description sidecar', e); + } } return result; } diff --git a/lib/services/file_service.dart b/lib/services/file_service.dart index 6c7216f..681892d 100644 --- a/lib/services/file_service.dart +++ b/lib/services/file_service.dart @@ -10,6 +10,7 @@ import '../l10n/app_localizations.dart'; import '../models/settings.dart'; import '../models/chart.dart'; import '../models/slide.dart'; +import '../utils/log.dart'; import 'annotation_codec.dart'; import 'caption_service.dart'; import 'image_service.dart'; @@ -64,6 +65,19 @@ class FileService { ThemeProfile activeProfileFor({String? projectPath}) => resolveThemeProfile(_themeProfile(), projectPath: projectPath); + /// Resolve a project-relative [path] to an absolute path strictly inside + /// [projectPath], or null for absolute paths or `../` escapes. Used for file + /// references an untrusted deck controls (e.g. a chart's linked CSV) so it + /// can't read arbitrary files outside its own folder. + static String? _projectFile(String? projectPath, String path) { + if (projectPath == null || path.trim().isEmpty || p.isAbsolute(path)) { + return null; + } + final abs = p.normalize(p.join(projectPath, path)); + if (abs != projectPath && !p.isWithin(projectPath, abs)) return null; + return abs; + } + ThemeProfile resolveThemeProfile( ThemeProfile profile, { String? projectPath, @@ -112,7 +126,11 @@ class FileService { List entries; try { entries = await dir.list(followLinks: false).toList(); - } catch (_) { + } catch (e) { + logWarning( + 'FileService.scanPresentations: directory listing failed', + e, + ); return; } for (final entity in entries) { @@ -124,7 +142,8 @@ class FileService { String content; try { content = await entity.readAsString(); - } catch (_) { + } catch (e) { + logWarning('FileService.scanPresentations: file not readable', e); continue; } final deck = await openDeck(entity.path, content: content); @@ -190,8 +209,9 @@ class FileService { hydrated.slides, ); if (map.isNotEmpty) return hydrated.copyWith(annotations: map); - } catch (_) { + } catch (e) { // A broken sidecar must never block opening the deck. + logWarning('FileService.openDeck: annotation sidecar unreadable', e); } } } @@ -229,11 +249,11 @@ class FileService { slides.add(s); continue; } - final abs = p.isAbsolute(spec.source!) - ? spec.source! - : p.join(deck.projectPath!, spec.source!); - final file = File(abs); - if (!await file.exists()) { + // A chart's CSV link must stay inside the project (no absolute paths or + // `../` escapes) — otherwise an untrusted deck could read arbitrary files. + final abs = _projectFile(deck.projectPath, spec.source!); + final file = abs == null ? null : File(abs); + if (file == null || !await file.exists()) { slides.add(s); continue; } @@ -241,7 +261,8 @@ class FileService { final csv = await file.readAsString(); slides.add(s.copyWith(customMarkdown: spec.withCsv(csv).toBlock())); changed = true; - } catch (_) { + } catch (e) { + logWarning('FileService._hydrateCharts: chart CSV unreadable', e); slides.add(s); } } @@ -325,9 +346,18 @@ class FileService { /// bestand toe onder `/` en geef dat pad terug. String? addAsset(String path, String subdir) { if (path.trim().isEmpty) return null; - final abs = p.isAbsolute(path) - ? path - : (deck.projectPath != null ? p.join(deck.projectPath!, path) : path); + final String abs; + if (p.isAbsolute(path)) { + // Absolute paths come from the picker (the user explicitly chose them). + abs = path; + } else if (deck.projectPath != null) { + // A relative asset must not escape the project via `../`. + final resolved = _projectFile(deck.projectPath, path); + if (resolved == null) return null; + abs = resolved; + } else { + abs = path; + } final file = File(abs); if (!file.existsSync()) return null; final rel = p.posix.join(subdir, p.basename(abs)); @@ -415,7 +445,8 @@ class FileService { profile, logoRel == null ? null : '../$logoRel', ); - } catch (_) { + } catch (e) { + logWarning('FileService._packageThemeCss: theme asset not bundled', e); return null; } } @@ -429,7 +460,8 @@ class FileService { final Archive archive; try { archive = ZipDecoder().decodeBytes(zipBytes); - } catch (_) { + } catch (e, s) { + logError('FileService.importPackageBytes: ZIP decode failed', e, s); return null; } @@ -448,14 +480,33 @@ class FileService { final destDir = _uniqueDir(destParentDir, folderName); await destDir.create(recursive: true); - for (final f in archive.files) { - if (!f.isFile) continue; - final out = File(p.join(destDir.path, f.name)); - await out.parent.create(recursive: true); - await out.writeAsBytes(f.content as List, flush: true); + // Resolve an archive entry name to a path strictly inside [destDir], or + // null when it would escape (zip-slip: `../`, absolute paths, …). + String? safeOutPath(String entryName) { + final resolved = p.normalize(p.join(destDir.path, entryName)); + if (resolved != destDir.path && !p.isWithin(destDir.path, resolved)) { + return null; + } + return resolved; } - return p.join(destDir.path, mdEntry.name); + var extracted = 0; + for (final f in archive.files) { + if (!f.isFile) continue; + final outPath = safeOutPath(f.name); + if (outPath == null) continue; // skip path-traversal entries + final content = f.content as List; + // Bound total extracted size so a small zip can't fill the disk (zip bomb). + extracted += content.length; + if (extracted > _maxDownloadBytes) break; + final out = File(outPath); + await out.parent.create(recursive: true); + await out.writeAsBytes(content, flush: true); + } + + // The main markdown must itself resolve inside the extraction folder. + final mdPath = safeOutPath(mdEntry.name); + return mdPath; } Directory _uniqueDir(String parent, String name) { @@ -471,26 +522,65 @@ class FileService { /// Download een presentatie vanaf [url]. Een zip-pakket wordt uitgepakt; /// platte markdown wordt als losse `.md` opgeslagen. Geeft het pad naar het /// markdown-bestand terug. + /// Cap on how much we download / extract, to bound memory and disk use. + static const _maxDownloadBytes = 64 * 1024 * 1024; // 64 MB + + /// Hosts an import must never reach (loopback, private and link-local ranges) + /// so a deck URL can't be used to probe the local machine or intranet (SSRF). + static bool _isBlockedHost(String host) { + final h = host.toLowerCase(); + if (h.isEmpty || h == 'localhost' || h.endsWith('.localhost')) return true; + final addr = InternetAddress.tryParse(host); + if (addr == null) return false; // a hostname; can't classify offline + if (addr.isLoopback || addr.isLinkLocal || addr.isMulticast) return true; + final raw = addr.rawAddress; + if (addr.type == InternetAddressType.IPv4) { + final a = raw[0], b = raw[1]; + if (a == 0 || a == 10 || a == 127) { + return true; // this-host/private/loopback + } + if (a == 172 && b >= 16 && b <= 31) return true; // 172.16.0.0/12 + if (a == 192 && b == 168) return true; // 192.168.0.0/16 + if (a == 169 && b == 254) return true; // 169.254.0.0/16 link-local + } else if ((raw[0] & 0xfe) == 0xfc) { + return true; // fc00::/7 unique-local + } + return false; + } + Future importFromUrl(String url, String destParentDir) async { final uri = Uri.tryParse(url.trim()); if (uri == null || !uri.hasScheme) return null; + // Only fetch over web schemes, and never reach private/loopback hosts. + final scheme = uri.scheme.toLowerCase(); + if (scheme != 'http' && scheme != 'https') return null; + if (_isBlockedHost(uri.host)) return null; final List bytes; try { - final client = HttpClient(); + final client = HttpClient() + ..connectionTimeout = const Duration(seconds: 15); try { final request = await client.getUrl(uri); - final response = await request.close(); + // Don't auto-follow redirects: a 3xx could point at a private host and + // bypass the SSRF check above. + request.followRedirects = false; + final response = await request.close().timeout( + const Duration(seconds: 30), + ); if (response.statusCode != 200) return null; + if (response.contentLength > _maxDownloadBytes) return null; final builder = BytesBuilder(copy: false); await for (final chunk in response) { builder.add(chunk); + if (builder.length > _maxDownloadBytes) return null; // runaway body } bytes = builder.takeBytes(); } finally { client.close(force: true); } - } catch (_) { + } catch (e) { + logError('FileService.importFromUrl: download failed', e); return null; } @@ -509,7 +599,8 @@ class FileService { final String markdown; try { markdown = utf8.decode(bytes); - } catch (_) { + } catch (e, s) { + logError('FileService.importFromUrl: UTF-8 decode failed', e, s); return null; } if (!markdown.contains('marp') && !markdown.contains('---')) return null; @@ -630,8 +721,9 @@ class FileService { 'assets/themes/ocideck.css', )).replaceFirst('@theme ocideck', '@theme $safeThemeName'); await dest.writeAsString(_buildThemeCss(base, profile, logoUrl)); - } catch (_) { + } catch (e) { // Asset not bundled in this build context; skip + logWarning('FileService._writeTheme: theme asset not bundled', e); } } diff --git a/lib/services/image_dedup_service.dart b/lib/services/image_dedup_service.dart index 2af529d..e205815 100644 --- a/lib/services/image_dedup_service.dart +++ b/lib/services/image_dedup_service.dart @@ -1,6 +1,7 @@ import 'dart:io'; import 'package:crypto/crypto.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../utils/log.dart'; /// Vindt exacte duplicaten tussen afbeeldingen op basis van hun md5-checksum, /// zodat de bibliotheek opgeschoond kan worden. Bestanden worden eerst op @@ -20,7 +21,9 @@ class ImageDedupService { try { final size = File(path).statSync().size; bySize.putIfAbsent(size, () => []).add(path); - } catch (_) {} + } catch (e) { + logWarning('ImageDedupService.findDuplicateGroups: stat for size', e); + } } // Stap 2: alleen binnen gelijke groottes de md5 berekenen. @@ -32,7 +35,9 @@ class ImageDedupService { try { final digest = await md5.bind(File(path).openRead()).single; byHash.putIfAbsent(digest.toString(), () => []).add(path); - } catch (_) {} + } catch (e) { + logWarning('ImageDedupService.findDuplicateGroups: md5 hash', e); + } } for (final group in byHash.values) { if (group.length >= 2) groups.add(group); @@ -51,7 +56,8 @@ class ImageDedupService { DateTime modifiedOf(String path) { try { return File(path).statSync().modified; - } catch (_) { + } catch (e) { + logWarning('ImageDedupService.chooseKeeper: stat modified time', e); return DateTime.fromMillisecondsSinceEpoch(0); } } diff --git a/lib/services/image_reference_service.dart b/lib/services/image_reference_service.dart index 485cabb..376adea 100644 --- a/lib/services/image_reference_service.dart +++ b/lib/services/image_reference_service.dart @@ -1,6 +1,7 @@ import 'dart:io'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:path/path.dart' as p; +import '../utils/log.dart'; /// Vindt en herschrijft afbeeldingsverwijzingen (`![…](pad)`) in /// Marp-markdownbestanden op schijf. Zo gaan bij het opruimen van duplicaten @@ -31,7 +32,8 @@ class ImageReferenceService { List entries; try { entries = await dir.list(followLinks: false).toList(); - } catch (_) { + } catch (e) { + logWarning('ImageReferenceService.findDeckFiles: list directory', e); return; } for (final entity in entries) { @@ -69,7 +71,8 @@ class ImageReferenceService { String content; try { content = await File(deckFile).readAsString(); - } catch (_) { + } catch (e) { + logWarning('ImageReferenceService.countReferences: read deck file', e); continue; } final mdDir = p.dirname(deckFile); @@ -100,7 +103,8 @@ class ImageReferenceService { String content; try { content = await File(deckFile).readAsString(); - } catch (_) { + } catch (e) { + logWarning('ImageReferenceService.referencingFiles: read deck file', e); continue; } final mdDir = p.dirname(deckFile); @@ -127,7 +131,8 @@ class ImageReferenceService { String content; try { content = await file.readAsString(); - } catch (_) { + } catch (e) { + logWarning('ImageReferenceService.replaceReferences: read deck file', e); return false; } final mdDir = p.dirname(deckFile); @@ -150,7 +155,8 @@ class ImageReferenceService { if (!changed) return false; try { await file.writeAsString(updated); - } catch (_) { + } catch (e) { + logWarning('ImageReferenceService.replaceReferences: write deck file', e); return false; } return true; @@ -159,7 +165,9 @@ class ImageReferenceService { String? _resolve(String ref, String mdDir) { final cleaned = ref.trim(); if (cleaned.isEmpty || cleaned.contains('://')) return null; - return p.normalize(p.isAbsolute(cleaned) ? cleaned : p.join(mdDir, cleaned)); + return p.normalize( + p.isAbsolute(cleaned) ? cleaned : p.join(mdDir, cleaned), + ); } } diff --git a/lib/services/image_service.dart b/lib/services/image_service.dart index 68f5d72..429cbeb 100644 --- a/lib/services/image_service.dart +++ b/lib/services/image_service.dart @@ -6,6 +6,7 @@ import 'package:path_provider/path_provider.dart'; import 'package:path/path.dart' as p; import '../l10n/app_localizations.dart'; import '../models/slide.dart'; +import '../utils/log.dart'; class ImageService { final String Function() _languageCode; @@ -46,7 +47,8 @@ class ImageService { if (bytes.isEmpty) return false; await Pasteboard.writeImage(bytes); return true; - } catch (_) { + } catch (e) { + logError('ImageService.copyImageBytesToClipboard: write image', e); return false; } } @@ -60,7 +62,8 @@ class ImageService { final file = File(path); if (!await file.exists()) return false; return copyImageBytesToClipboard(await file.readAsBytes()); - } catch (_) { + } catch (e) { + logWarning('ImageService.copyImageToClipboard: read image file', e); return false; } } diff --git a/lib/services/markdown_service.dart b/lib/services/markdown_service.dart index fbfef83..d092567 100644 --- a/lib/services/markdown_service.dart +++ b/lib/services/markdown_service.dart @@ -5,6 +5,7 @@ import '../models/chart.dart'; import '../models/deck.dart'; import '../models/settings.dart'; import '../models/slide.dart'; +import '../utils/log.dart'; const _uuid = Uuid(); @@ -528,7 +529,8 @@ class MarkdownService { static String _decodeText(String encoded) { try { return utf8.decode(base64Url.decode(encoded.trim())); - } catch (_) { + } catch (e, s) { + logError('MarkdownService._decodeText: base64/utf8 decode', e, s); return ''; } } @@ -538,7 +540,9 @@ class MarkdownService { final decoded = utf8.decode(base64Url.decode(encoded.trim())); final raw = jsonDecode(decoded); if (raw is List) return raw.map((v) => v.toString()).toList(); - } catch (_) {} + } catch (e, s) { + logError('MarkdownService._decodeBullets: base64/utf8/json decode', e, s); + } return const []; } @@ -646,7 +650,8 @@ class MarkdownService { Deck? parseDeck(String markdown, {String? filePath}) { try { return _doParse(markdown, filePath: filePath); - } catch (_) { + } catch (e, s) { + logError('MarkdownService.parseDeck: parse markdown', e, s); return null; } } @@ -692,11 +697,22 @@ class MarkdownService { } else if (line.startsWith('tlp:')) { tlp = TlpLevelX.fromKey(line.substring(4)); } else if (line.startsWith('ocideck_style_profile:')) { - final encoded = line.substring(22).trim(); - final decoded = utf8.decode(base64Url.decode(encoded)); - themeProfile = ThemeProfile.fromJson( - Map.from(jsonDecode(decoded) as Map), - ); + // Best-effort: a corrupt profile token must not fail the whole + // parse (which would blank the audience window). Keep the default. + try { + final encoded = line.substring(22).trim(); + final decoded = utf8.decode(base64Url.decode(encoded)); + themeProfile = ThemeProfile.fromJson( + Map.from(jsonDecode(decoded) as Map), + ); + } catch (e, s) { + logError( + 'MarkdownService._doParse: decode ocideck_style_profile', + e, + s, + ); + // Leave themeProfile at its default. + } } } content = content.substring(end + 5).trim(); diff --git a/lib/services/marp_html_service.dart b/lib/services/marp_html_service.dart index aa509b1..108122c 100644 --- a/lib/services/marp_html_service.dart +++ b/lib/services/marp_html_service.dart @@ -6,6 +6,7 @@ import 'package:flutter/services.dart' show rootBundle; import '../models/chart.dart'; import '../models/settings.dart'; +import '../utils/log.dart'; /// Builds a single, self-contained HTML file from a deck's Marp Markdown. /// @@ -38,6 +39,7 @@ class MarpHtmlService { /// colours and font so the export matches the in-app / PDF look. Future build(String deckMarkdown, {ThemeProfile? theme}) async { final marked = await loadAsset('$_assetDir/marked.min.js'); + final purify = await loadAsset('$_assetDir/purify.min.js'); final hljs = await loadAsset('$_assetDir/highlight.min.js'); final hljsCss = await loadAsset('$_assetDir/highlight.css'); final mathjax = await loadAsset('$_assetDir/tex-svg.js'); @@ -61,6 +63,7 @@ class MarpHtmlService { '' '' '${inline(marked)}' + '${inline(purify)}' '${inline(hljs)}' '${inline(mathjax)}' '${inline(mermaid)}' @@ -97,11 +100,16 @@ class MarpHtmlService { } /// Neutralise any ` element. Safe for both JS (string contexts) and - /// the embedded Markdown payloads. - static String _guard(String s) => s - .replaceAll(' element. Case-insensitive — `` must not + /// slip through. Safe for both JS (string contexts) and the embedded Markdown + /// payloads. + static final RegExp _scriptClose = RegExp( + r' + s.replaceAllMapped(_scriptClose, (m) => '<\\/${m.group(1)}'); // ── Charts → inline SVG ──────────────────────────────────────────────────── @@ -563,7 +571,9 @@ class MarpHtmlService { final range = (rawHi - rawLo).abs(); final r = range <= 0 ? 1.0 : range; final rawStep = r / 4; - final mag = math.pow(10, (math.log(rawStep) / math.ln10).floor()).toDouble(); + final mag = math + .pow(10, (math.log(rawStep) / math.ln10).floor()) + .toDouble(); final norm = rawStep / mag; final niceNorm = norm < 1.5 ? 1.0 @@ -640,7 +650,8 @@ class MarpHtmlService { return "@font-face{font-family:'EB Garamond';font-weight:400 800;" "font-style:normal;src:url(data:font/ttf;base64,$b64) " "format('truetype');}"; - } catch (_) { + } catch (e) { + logWarning('MarpHtmlService._ebGaramondFontFace: load font asset', e); return ''; // Fall back to the CSS font stack if the asset is missing. } } @@ -672,7 +683,11 @@ body{background:#1e1e1e;font-family:-apple-system,"Segoe UI",Roboto,Helvetica,Ar var holder=sec.querySelector('script[type="text/markdown"]'); var src=holder?holder.textContent:''; var div=document.createElement('div');div.className='content'; - div.innerHTML=window.marked?marked.parse(src):src; + var html=window.marked?marked.parse(src):src; + // Sanitise rendered Markdown before it touches the DOM: a deck must not be + // able to run script/onerror/javascript: payloads when the export is opened. + // If the sanitiser somehow isn't present, fail closed to plain text. + if(window.DOMPurify){div.innerHTML=DOMPurify.sanitize(html);}else{div.textContent=src;} sec.innerHTML='';sec.appendChild(div); }); document.querySelectorAll('code.language-mermaid').forEach(function(code){ diff --git a/lib/services/recovery_service.dart b/lib/services/recovery_service.dart index af5fceb..be981ca 100644 --- a/lib/services/recovery_service.dart +++ b/lib/services/recovery_service.dart @@ -4,6 +4,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:path/path.dart' as p; import 'package:path_provider/path_provider.dart'; +import '../utils/log.dart'; + /// Eén automatisch bewaard herstelbestand voor een (nog) niet-opgeslagen deck. class RecoverySnapshot { final String id; @@ -72,7 +74,8 @@ class RecoveryService { dir, snapshot.id, ).writeAsString(jsonEncode(snapshot.toJson()), flush: true); - } catch (_) { + } catch (e) { + logWarning('RecoveryService.save: write recovery snapshot', e); // Autosave mag nooit de app verstoren. } } @@ -81,7 +84,9 @@ class RecoveryService { try { final file = _file(await _dir(), id); if (file.existsSync()) await file.delete(); - } catch (_) {} + } catch (e) { + logWarning('RecoveryService.discard: delete recovery file', e); + } } Future> loadAll() async { @@ -93,12 +98,15 @@ class RecoveryService { try { final data = jsonDecode(await entry.readAsString()); out.add(RecoverySnapshot.fromJson(Map.from(data))); - } catch (_) {} + } catch (e, s) { + logError('RecoveryService.loadAll: decode recovery snapshot', e, s); + } } } out.sort((a, b) => b.savedAt.compareTo(a.savedAt)); return out; - } catch (_) { + } catch (e) { + logWarning('RecoveryService.loadAll: list recovery dir', e); return const []; } } @@ -110,10 +118,14 @@ class RecoveryService { if (entry is File && entry.path.endsWith('.json')) { try { await entry.delete(); - } catch (_) {} + } catch (e) { + logWarning('RecoveryService.clearAll: delete recovery file', e); + } } } - } catch (_) {} + } catch (e) { + logWarning('RecoveryService.clearAll: list recovery dir', e); + } } } diff --git a/lib/state/consent_provider.dart b/lib/state/consent_provider.dart index 37b12a6..df62c4d 100644 --- a/lib/state/consent_provider.dart +++ b/lib/state/consent_provider.dart @@ -1,10 +1,10 @@ +import 'package:flutter/foundation.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:shared_preferences/shared_preferences.dart'; const _consentKey = 'app_consent_accepted'; -final consentProvider = - NotifierProvider(() { +final consentProvider = NotifierProvider(() { return ConsentNotifier(); }); @@ -12,15 +12,9 @@ class ConsentState { final bool hasAccepted; final bool isLoading; - const ConsentState({ - required this.hasAccepted, - this.isLoading = false, - }); + const ConsentState({required this.hasAccepted, this.isLoading = false}); - ConsentState copyWith({ - bool? hasAccepted, - bool? isLoading, - }) { + ConsentState copyWith({bool? hasAccepted, bool? isLoading}) { return ConsentState( hasAccepted: hasAccepted ?? this.hasAccepted, isLoading: isLoading ?? this.isLoading, @@ -41,6 +35,8 @@ class ConsentNotifier extends Notifier { final hasAccepted = prefs.getBool(_consentKey) ?? false; state = state.copyWith(hasAccepted: hasAccepted, isLoading: false); } catch (e) { + // Can't read the flag: fail closed (gate stays up) but don't hang loading. + debugPrint('ConsentNotifier: could not read consent flag: $e'); state = state.copyWith(isLoading: false); } } @@ -51,6 +47,9 @@ class ConsentNotifier extends Notifier { await prefs.setBool(_consentKey, true); state = state.copyWith(hasAccepted: true); } catch (e) { + // Persisting failed; let the user through this session, but the gate will + // reappear next launch. Surface the failure instead of swallowing it. + debugPrint('ConsentNotifier: could not persist consent: $e'); state = state.copyWith(hasAccepted: true); } } @@ -61,6 +60,7 @@ class ConsentNotifier extends Notifier { await prefs.setBool(_consentKey, false); state = state.copyWith(hasAccepted: false); } catch (e) { + debugPrint('ConsentNotifier: could not persist consent revocation: $e'); state = state.copyWith(hasAccepted: false); } } diff --git a/lib/state/deck_provider.dart b/lib/state/deck_provider.dart index bd44276..77c0b17 100644 --- a/lib/state/deck_provider.dart +++ b/lib/state/deck_provider.dart @@ -201,7 +201,12 @@ class DeckNotifier extends StateNotifier { void removeSlide(int index) { final deck = state.deck; - if (deck == null || deck.slides.length <= 1) return; + if (deck == null || + deck.slides.length <= 1 || + index < 0 || + index >= deck.slides.length) { + return; + } final slides = List.from(deck.slides)..removeAt(index); _mutate(deck.copyWith(slides: slides)); } @@ -254,7 +259,7 @@ class DeckNotifier extends StateNotifier { void duplicateSlide(int index) { final deck = state.deck; - if (deck == null) return; + if (deck == null || index < 0 || index >= deck.slides.length) return; final slides = List.from(deck.slides); slides.insert(index + 1, Slide.duplicate(slides[index])); _mutate(deck.copyWith(slides: slides)); @@ -272,7 +277,7 @@ class DeckNotifier extends StateNotifier { void updateSlide(int index, Slide updated) { final deck = state.deck; - if (deck == null) return; + if (deck == null || index < 0 || index >= deck.slides.length) return; final slides = List.from(deck.slides); slides[index] = updated; // Snel typen op dezelfde slide telt als één ongedaan-maken-stap. @@ -393,6 +398,9 @@ class DeckNotifier extends StateNotifier { title: sub(s.title), subtitle: sub(s.subtitle), bullets: [for (final b in s.bullets) sub(b)], + bullets2: [for (final b in s.bullets2) sub(b)], + columnTitle1: sub(s.columnTitle1), + columnTitle2: sub(s.columnTitle2), quote: sub(s.quote), quoteAuthor: sub(s.quoteAuthor), customMarkdown: sub(s.customMarkdown), @@ -414,6 +422,9 @@ class DeckNotifier extends StateNotifier { s.title, s.subtitle, ...s.bullets, + ...s.bullets2, + s.columnTitle1, + s.columnTitle2, s.quote, s.quoteAuthor, s.customMarkdown, diff --git a/lib/state/tabs_provider.dart b/lib/state/tabs_provider.dart index cb73b19..58449b1 100644 --- a/lib/state/tabs_provider.dart +++ b/lib/state/tabs_provider.dart @@ -101,9 +101,26 @@ class TabsNotifier extends StateNotifier { for (final sub in _subs.values) { sub.cancel(); } + // The tabs' notifiers are not disposed here: at teardown the widget tree is + // still unmounting and may read a tab one last time. The process is ending + // anyway. The per-close path (_disposeTab) is what prevents the real leak. super.dispose(); } + /// Tear down a tab that is being removed: stop listening to it and dispose + /// its notifiers so their listeners and undo/redo history are released. The + /// dispose is deferred to a microtask so any widget still referencing this + /// tab while it unmounts has finished before the notifiers go away. + void _disposeTab(TabInfo tab) { + _subs.remove(tab.id)?.cancel(); + final deckNotifier = tab.deckNotifier; + final editorNotifier = tab.editorNotifier; + Future.microtask(() { + deckNotifier.dispose(); + editorNotifier.dispose(); + }); + } + TabInfo _createTab() { final id = _nextId++; final recoveryId = _uuid.v4(); @@ -163,7 +180,7 @@ class TabsNotifier extends StateNotifier { // Een ongebruikt leeg begin-tabblad vervangen, anders toevoegen. final replaceEmpty = state.tabs.length == 1 && !state.tabs.first.isOpen; if (replaceEmpty) { - _subs.remove(state.tabs.first.id)?.cancel(); + _disposeTab(state.tabs.first); state = state.copyWith(tabs: restored, selectedIndex: 0); } else { final tabs = [...state.tabs, ...restored]; @@ -282,7 +299,7 @@ class TabsNotifier extends StateNotifier { } final tab = state.tabs[index]; _recovery.discard(tab.recoveryId); - _subs.remove(tab.id)?.cancel(); + _disposeTab(tab); final newTabs = List.from(state.tabs)..removeAt(index); final newSelected = index >= newTabs.length ? newTabs.length - 1 : index; state = state.copyWith(tabs: newTabs, selectedIndex: newSelected); diff --git a/lib/utils/log.dart b/lib/utils/log.dart new file mode 100644 index 0000000..67c0283 --- /dev/null +++ b/lib/utils/log.dart @@ -0,0 +1,41 @@ +/// Lightweight logging for failures the app deliberately swallows. +/// +/// Many call sites here used to be bare `catch (_) {}` blocks. Swallowing was +/// usually the *right* behaviour — a broken sidecar, an unreadable file or an +/// unsupported platform must never crash a presentation — but the failure then +/// vanished without a trace, which made real bugs invisible. Routing these +/// through [logError]/[logWarning] keeps the fail-soft behaviour while making +/// the cause observable. +/// +/// Records go to the `dart:developer` logging stream (DevTools / the VM +/// service), not stdout, so release builds stay quiet. Pass only an operation +/// description and the caught error object — never deck or file *contents*, +/// which can be personal data. +library; + +import 'dart:developer' as developer; + +const _name = 'ocideck'; + +// Severity levels mirror package:logging (WARNING = 900, SEVERE = 1000). +const int _levelWarning = 900; +const int _levelError = 1000; + +/// An unexpected failure that was handled by falling back. [op] is a short +/// description of what was attempted, e.g. `'openDeck: read annotation sidecar'`. +void logError(String op, Object error, [StackTrace? stack]) { + developer.log( + op, + name: _name, + error: error, + stackTrace: stack, + level: _levelError, + ); +} + +/// An expected-but-notable condition where the app fell back to a default +/// (e.g. an absent optional file, an unsupported platform capability). Lower +/// severity than [logError]; [error] is optional. +void logWarning(String op, [Object? error]) { + developer.log(op, name: _name, error: error, level: _levelWarning); +} diff --git a/lib/utils/url_launcher_util.dart b/lib/utils/url_launcher_util.dart index 252ca3f..69192d9 100644 --- a/lib/utils/url_launcher_util.dart +++ b/lib/utils/url_launcher_util.dart @@ -1,8 +1,14 @@ import 'package:url_launcher/url_launcher.dart'; +import 'log.dart'; + +/// Schemes a deck link may open. Anything else (file:, javascript:, custom app +/// schemes, …) is refused so a deck can't hand the OS a dangerous or +/// unexpected URI. +const _allowedUrlSchemes = {'https', 'http', 'mailto'}; /// Open een link uit slide-tekst in de externe browser. Kale domeinen -/// (zonder schema) krijgen automatisch `https://`. Faalt stil bij ongeldige -/// of niet-openbare URLs. +/// (zonder schema) krijgen automatisch `https://`. Faalt stil bij ongeldige, +/// niet-openbare of niet-toegestane URLs. Future openExternalUrl(String url) async { var u = url.trim(); if (u.isEmpty) return; @@ -11,11 +17,13 @@ Future openExternalUrl(String url) async { } final uri = Uri.tryParse(u); if (uri == null) return; + if (!_allowedUrlSchemes.contains(uri.scheme.toLowerCase())) return; try { if (await canLaunchUrl(uri)) { await launchUrl(uri, mode: LaunchMode.externalApplication); } - } catch (_) { + } catch (e) { + logWarning('openExternalUrl: launching external URL failed', e); // Nooit de presentatie laten crashen op een kapotte link. } } diff --git a/lib/widgets/app_shell.dart b/lib/widgets/app_shell.dart index 6ea141f..36723fb 100644 --- a/lib/widgets/app_shell.dart +++ b/lib/widgets/app_shell.dart @@ -30,173 +30,13 @@ import 'presentation/fullscreen_presenter.dart'; // ── Shared helpers ────────────────────────────────────────────────────────── -/// Open the search-based presentation picker and load the chosen file -/// (optionally jumping to a matched slide). -Future _openWithSearch( - BuildContext context, - WidgetRef ref, - String? initialDirectory, -) async { - final settings = ref.read(settingsProvider); - final result = await OpenPresentationDialog.show( - context, - fileService: ref.read(fileServiceProvider), - initialDirectory: initialDirectory ?? settings.homeDirectory, - ); - if (result == null) return; - await ref - .read(tabsProvider.notifier) - .openFileByPath(result.path, selectIndex: result.slideIndex); -} - -/// Vraag een URL op om een presentatie (pakket of markdown) op te halen. -Future _showUrlDialog(BuildContext context) { - final l10n = context.l10n; - final controller = TextEditingController(); - return showDialog( - context: context, - builder: (ctx) => AlertDialog( - title: Text(l10n.d('Importeren via URL')), - content: SizedBox( - width: 460, - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - l10n.d( - 'Plak de link naar een .ocideck-pakket of een Marp-markdownbestand.', - ), - style: const TextStyle(fontSize: 12, color: Color(0xFF64748B)), - ), - const SizedBox(height: 12), - TextField( - controller: controller, - autofocus: true, - keyboardType: TextInputType.url, - decoration: const InputDecoration( - hintText: 'https://…', - prefixIcon: Icon(Icons.link, size: 18), - isDense: true, - border: OutlineInputBorder(), - ), - onSubmitted: (v) => Navigator.pop(ctx, v), - ), - ], - ), - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(ctx), - child: Text(l10n.t('cancel')), - ), - ElevatedButton.icon( - onPressed: () => Navigator.pop(ctx, controller.text), - icon: const Icon(Icons.download, size: 16), - label: Text(l10n.d('Ophalen')), - ), - ], - ), - ); -} - -List _imageSearchPaths(String? projectPath, String? homeDirectory) { - final projectImagesPath = projectPath == null - ? null - : p.join(projectPath, 'images'); - return [?projectImagesPath, ?projectPath, ?homeDirectory]; -} - -String? _resolveImagePath(String path, String? projectPath) { - if (path.isEmpty) return null; - if (p.isAbsolute(path) || projectPath == null) return path; - return p.join(projectPath, path); -} - -List _imageUsages(WidgetRef ref, String absolutePath) { - final target = p.normalize(absolutePath); - final usages = []; - for (final tab in ref.read(tabsProvider).tabs) { - final deck = tab.deckNotifier.currentState.deck; - if (deck == null) continue; - for (var i = 0; i < deck.slides.length; i++) { - final slide = deck.slides[i]; - for (final candidate in [slide.imagePath, slide.imagePath2]) { - if (candidate.isEmpty) continue; - final resolved = p.normalize( - p.isAbsolute(candidate) - ? candidate - : p.join(deck.projectPath ?? '', candidate), - ); - if (resolved == target) { - usages.add('${tab.label} · slide ${i + 1}'); - break; - } - } - } - } - return usages; -} - -/// Wijs in alle open decks elke slideverwijzing naar [fromAbsolute] om naar -/// [toAbsolute]. Gebruikt door de afbeeldingenbibliotheek wanneer een md5- -/// duplicaat wordt opgeruimd, zodat slides het behouden bestand blijven tonen. -Future _replaceImageUsages( - WidgetRef ref, - String fromAbsolute, - String toAbsolute, -) async { - final target = p.normalize(fromAbsolute); - for (final tab in ref.read(tabsProvider).tabs) { - final notifier = tab.deckNotifier; - final deck = notifier.currentState.deck; - if (deck == null) continue; - final projectPath = deck.projectPath ?? ''; - - String resolve(String candidate) => p.normalize( - p.isAbsolute(candidate) ? candidate : p.join(projectPath, candidate), - ); - // Blijf relatief opslaan als de slide dat al deed en het nieuwe pad - // binnen het project ligt; anders absoluut. - String replacement(String candidate) { - if (p.isAbsolute(candidate) || projectPath.isEmpty) return toAbsolute; - return p.isWithin(projectPath, toAbsolute) - ? p.relative(toAbsolute, from: projectPath) - : toAbsolute; - } - - for (var i = 0; i < deck.slides.length; i++) { - final slide = deck.slides[i]; - var updated = slide; - if (slide.imagePath.isNotEmpty && resolve(slide.imagePath) == target) { - updated = updated.copyWith(imagePath: replacement(slide.imagePath)); - } - if (slide.imagePath2.isNotEmpty && resolve(slide.imagePath2) == target) { - updated = updated.copyWith(imagePath2: replacement(slide.imagePath2)); - } - if (!identical(updated, slide)) notifier.updateSlide(i, updated); - } - } -} - -List _slidesForPresentationOrExport(Deck deck) { - // Drop skipped slides and slides whose TLP classification is stricter than - // the level chosen for this presentation/export. - final slides = deck.slides - .where((s) => !s.skipped && slideVisibleAtTlp(s, deck.tlp)) - .toList(); - final closingMarkdown = deck.themeProfile.closingSlideMarkdown.trim(); - if (deck.themeProfile.closingSlideEnabled && closingMarkdown.isNotEmpty) { - slides.add( - Slide.create( - SlideType.freeMarkdown, - ).copyWith(customMarkdown: closingMarkdown), - ); - } - return slides; -} - -// ── App shell ───────────────────────────────────────────────────────────────── +// Shell sub-widgets and helpers, split into part files for navigability. +// These parts share this library's imports and private scope. +part 'shell/shell_actions.dart'; +part 'shell/tab_bar.dart'; +part 'shell/welcome_screen.dart'; +part 'shell/status_bar.dart'; +part 'shell/shell_overlays.dart'; class AppShell extends ConsumerStatefulWidget { const AppShell({super.key}); @@ -504,387 +344,6 @@ class _AppShellState extends ConsumerState with WindowListener { } } -/// Visuele hint terwijl bestanden boven het venster zweven. -class _DropOverlay extends StatelessWidget { - const _DropOverlay(); - - @override - Widget build(BuildContext context) { - return Positioned.fill( - child: IgnorePointer( - child: Container( - color: const Color(0xFF1C2B47).withValues(alpha: 0.55), - alignment: Alignment.center, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 22), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(14), - border: Border.all(color: const Color(0xFF60A5FA), width: 2), - ), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon( - Icons.file_download_outlined, - size: 40, - color: Color(0xFF2563EB), - ), - const SizedBox(height: 10), - Text( - context.l10n.d('Laat los om toe te voegen'), - style: const TextStyle( - fontSize: 15, - fontWeight: FontWeight.w600, - color: Color(0xFF1E293B), - ), - ), - const SizedBox(height: 4), - Text( - context.l10n.d( - 'Afbeeldingen → nieuwe slides · .md / .ocideck → openen', - ), - style: const TextStyle( - fontSize: 12, - color: Color(0xFF64748B), - ), - ), - ], - ), - ), - ), - ), - ); - } -} - -// ── Tab bar ─────────────────────────────────────────────────────────────────── - -class _AppTabBar extends StatelessWidget { - final TabsState tabsState; - final ValueChanged onSelect; - final ValueChanged onClose; - final VoidCallback onAdd; - - const _AppTabBar({ - required this.tabsState, - required this.onSelect, - required this.onClose, - required this.onAdd, - }); - - @override - Widget build(BuildContext context) { - final l10n = context.l10n; - final palette = Theme.of(context).extension()!; - return Container( - height: 36, - color: palette.panel, - child: Row( - children: [ - Expanded( - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: Row( - children: [ - for (int i = 0; i < tabsState.tabs.length; i++) - _TabChip( - tab: tabsState.tabs[i], - isActive: i == tabsState.clampedIndex, - showClose: - tabsState.tabs.length > 1 || tabsState.tabs[i].isOpen, - panelText: palette.panelText, - accent: Theme.of(context).colorScheme.secondary, - onTap: () => onSelect(i), - onClose: () => onClose(i), - ), - ], - ), - ), - ), - Tooltip( - message: l10n.t('newTab'), - child: InkWell( - onTap: onAdd, - child: SizedBox( - width: 36, - height: 36, - child: Icon( - Icons.add, - size: 16, - color: palette.panelText.withValues(alpha: 0.55), - ), - ), - ), - ), - ], - ), - ); - } -} - -class _TabChip extends StatelessWidget { - final TabInfo tab; - final bool isActive; - final bool showClose; - final VoidCallback onTap; - final VoidCallback onClose; - final Color panelText; - final Color accent; - - const _TabChip({ - required this.tab, - required this.isActive, - required this.showClose, - required this.onTap, - required this.onClose, - required this.panelText, - required this.accent, - }); - - @override - Widget build(BuildContext context) { - return GestureDetector( - onTap: onTap, - child: Container( - constraints: const BoxConstraints(minWidth: 80, maxWidth: 200), - height: 36, - decoration: BoxDecoration( - color: isActive - ? panelText.withValues(alpha: 0.12) - : Colors.transparent, - border: Border( - bottom: BorderSide( - color: isActive ? accent : Colors.transparent, - width: 2, - ), - ), - ), - padding: const EdgeInsets.symmetric(horizontal: 10), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (tab.isDirty) - Container( - width: 6, - height: 6, - margin: const EdgeInsets.only(right: 5), - decoration: const BoxDecoration( - color: Colors.orangeAccent, - shape: BoxShape.circle, - ), - ), - Flexible( - child: Text( - tab.label, - style: TextStyle( - fontSize: 12, - color: isActive - ? panelText - : panelText.withValues(alpha: 0.72), - fontWeight: isActive ? FontWeight.w600 : FontWeight.normal, - ), - overflow: TextOverflow.ellipsis, - ), - ), - if (showClose) ...[ - const SizedBox(width: 4), - InkWell( - onTap: onClose, - borderRadius: BorderRadius.circular(3), - child: Padding( - padding: const EdgeInsets.all(2), - child: Icon( - Icons.close, - size: 12, - color: panelText.withValues(alpha: 0.55), - ), - ), - ), - ], - ], - ), - ), - ); - } -} - -// ── Per-tab content ─────────────────────────────────────────────────────────── - -class _TabContent extends ConsumerWidget { - const _TabContent(); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final isOpen = ref.watch(deckProvider.select((s) => s.isOpen)); - if (!isOpen) return const _WelcomeScreen(); - return _MainLayout(exportService: ExportService()); - } -} - -// ── Welcome screen ──────────────────────────────────────────────────────────── - -class _WelcomeScreen extends ConsumerWidget { - const _WelcomeScreen(); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final l10n = context.l10n; - final theme = Theme.of(context); - final palette = theme.extension()!; - final homeDir = ref.watch(settingsProvider.select((s) => s.homeDirectory)); - final recentFiles = ref.watch( - settingsProvider.select((s) => s.recentFiles), - ); - - return Scaffold( - backgroundColor: theme.scaffoldBackgroundColor, - body: Row( - children: [ - // ── Midden: logo + knoppen ───────────────────────────────────── - Expanded( - child: Align( - alignment: const Alignment(-0.15, 0.12), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Semantics( - label: 'De Winter Information Solutions', - image: true, - child: Image.asset( - 'assets/images/de-winter-wittegeheel.png', - width: 320, - fit: BoxFit.contain, - filterQuality: FilterQuality.high, - ), - ), - const SizedBox(height: 36), - SizedBox( - width: 220, - child: ElevatedButton.icon( - onPressed: () => _newDeck(context, ref), - icon: const Icon(Icons.add, size: 18), - label: Text(l10n.t('newPresentation')), - ), - ), - const SizedBox(height: 12), - SizedBox( - width: 220, - child: OutlinedButton.icon( - onPressed: () => _openWithSearch(context, ref, homeDir), - icon: const Icon(Icons.folder_open_outlined, size: 18), - label: Text(l10n.t('open')), - ), - ), - const SizedBox(height: 8), - TextButton.icon( - onPressed: () => SettingsDialog.show(context), - icon: const Icon(Icons.settings_outlined, size: 17), - label: Text(l10n.t('settings')), - ), - ], - ), - ), - ), - // ── Rechts: recente bestanden ────────────────────────────────── - if (recentFiles.isNotEmpty) - Container( - width: 280, - decoration: BoxDecoration( - color: theme.colorScheme.surface, - border: Border( - left: BorderSide(color: theme.colorScheme.outlineVariant), - ), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.fromLTRB(16, 20, 16, 10), - child: Text( - l10n.t('recentPresentations'), - style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.w700, - color: palette.mutedText, - letterSpacing: 0.8, - ), - ), - ), - Expanded( - child: ListView.builder( - padding: const EdgeInsets.only(bottom: 16), - itemCount: recentFiles.length, - itemBuilder: (_, i) { - final path = recentFiles[i]; - final name = path.split('/').last.replaceAll('.md', ''); - return InkWell( - onTap: () => ref - .read(tabsProvider.notifier) - .openFileByPath(path), - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 10, - ), - child: Row( - children: [ - Icon( - Icons.slideshow_outlined, - size: 16, - color: theme.colorScheme.onSurfaceVariant, - ), - const SizedBox(width: 10), - Expanded( - child: Column( - crossAxisAlignment: - CrossAxisAlignment.start, - children: [ - Text( - name, - style: TextStyle( - fontSize: 13, - fontWeight: FontWeight.w500, - color: theme.colorScheme.onSurface, - ), - overflow: TextOverflow.ellipsis, - ), - Text( - path, - style: TextStyle( - fontSize: 10, - color: palette.mutedText, - ), - overflow: TextOverflow.ellipsis, - ), - ], - ), - ), - ], - ), - ), - ); - }, - ), - ), - ], - ), - ), - ], - ), - ); - } - - Future _newDeck(BuildContext context, WidgetRef ref) async { - final title = await NewDeckDialog.show(context); - if (title != null) { - ref.read(tabsProvider.notifier).newDeckInCurrentTab(title); - } - } -} - -// ── Main 2-panel layout ─────────────────────────────────────────────────────── - class _MainLayout extends ConsumerStatefulWidget { final ExportService exportService; @@ -1535,396 +994,3 @@ class _MainLayoutState extends ConsumerState<_MainLayout> { } // ── AppBar helpers ──────────────────────────────────────────────────────────── - -class _DeckStatusBar extends StatelessWidget { - final Deck deck; - final DeckState deckState; - final String? exportDirectory; - final Future Function() onSave; - final VoidCallback? onExport; - final String exportTooltip; - - const _DeckStatusBar({ - required this.deck, - required this.deckState, - required this.exportDirectory, - required this.onSave, - required this.onExport, - required this.exportTooltip, - }); - - @override - Widget build(BuildContext context) { - final l10n = context.l10n; - final skipped = deck.slides.where((s) => s.skipped).length; - final fileLabel = deckState.filePath == null - ? l10n.t('notSavedYet') - : p.basename(deckState.filePath!); - final saveLabel = deckState.isDirty ? l10n.t('unsaved') : l10n.t('saved'); - final exportLabel = exportDirectory == null - ? l10n.t('exportNextToDeck') - : '${l10n.t('exportFolder')}: ${p.basename(exportDirectory!)}'; - - final theme = Theme.of(context); - return Material( - color: theme.colorScheme.surface, - child: Container( - height: 30, - padding: const EdgeInsets.symmetric(horizontal: 10), - decoration: BoxDecoration( - border: Border( - top: BorderSide(color: theme.colorScheme.outlineVariant), - ), - ), - child: Row( - children: [ - _StatusAction( - icon: deckState.isDirty - ? Icons.radio_button_checked - : Icons.check_circle_outline, - label: saveLabel, - tooltip: deckState.isDirty - ? l10n.t('unsavedChanges') - : l10n.t('noUnsavedChanges'), - color: deckState.isDirty - ? const Color(0xFFD97706) - : const Color(0xFF15803D), - onTap: () => onSave(), - ), - const _StatusDivider(), - _StatusItem( - icon: Icons.description_outlined, - label: fileLabel, - tooltip: deckState.filePath ?? l10n.t('noFileYet'), - ), - const _StatusDivider(), - _StatusItem( - icon: Icons.slideshow_outlined, - label: skipped == 0 - ? '${deck.slides.length} ${l10n.t('slides')}' - : '${deck.slides.length} ${l10n.t('slides')} · $skipped ${l10n.t('skipped')}', - tooltip: skipped == 0 - ? l10n.t('allSlidesIncluded') - : '$skipped ${l10n.t('skippedSlidesExcluded')}', - color: skipped == 0 ? null : const Color(0xFF8A6D3B), - ), - const _StatusDivider(), - _StatusItem( - icon: Icons.palette_outlined, - label: deck.themeProfile.name, - tooltip: '${l10n.t('styleProfile')}: ${deck.themeProfile.name}', - ), - if (deck.tlp != TlpLevel.none) ...[ - const _StatusDivider(), - _StatusItem( - icon: Icons.shield_outlined, - label: deck.tlp.label, - tooltip: '${l10n.t('classification')}: ${deck.tlp.label}', - color: Color(deck.tlp.foreground), - ), - ], - const Spacer(), - _StatusItem( - icon: Icons.folder_outlined, - label: exportLabel, - tooltip: exportDirectory ?? l10n.t('exportsNextToDeck'), - ), - const SizedBox(width: 6), - _StatusAction( - icon: Icons.upload_file_outlined, - label: l10n.t('export'), - tooltip: exportTooltip, - onTap: onExport, - ), - ], - ), - ), - ); - } -} - -class _StatusItem extends StatelessWidget { - final IconData icon; - final String label; - final String tooltip; - final Color? color; - - const _StatusItem({ - required this.icon, - required this.label, - required this.tooltip, - this.color, - }); - - @override - Widget build(BuildContext context) { - final fg = color ?? Theme.of(context).colorScheme.onSurfaceVariant; - return Tooltip( - message: tooltip, - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(icon, size: 13, color: fg), - const SizedBox(width: 4), - ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 210), - child: Text( - label, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontSize: 11, - color: fg, - fontWeight: color == null ? FontWeight.normal : FontWeight.w600, - ), - ), - ), - ], - ), - ); - } -} - -class _StatusAction extends StatelessWidget { - final IconData icon; - final String label; - final String tooltip; - final Color? color; - final VoidCallback? onTap; - - const _StatusAction({ - required this.icon, - required this.label, - required this.tooltip, - this.color, - this.onTap, - }); - - @override - Widget build(BuildContext context) { - final enabled = onTap != null; - final fg = enabled - ? (color ?? Theme.of(context).colorScheme.secondary) - : Theme.of(context).disabledColor; - return Tooltip( - message: tooltip, - child: InkWell( - onTap: onTap, - borderRadius: BorderRadius.circular(4), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 3), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(icon, size: 13, color: fg), - const SizedBox(width: 4), - Text( - label, - style: TextStyle( - fontSize: 11, - color: fg, - fontWeight: enabled ? FontWeight.w600 : FontWeight.normal, - ), - ), - ], - ), - ), - ), - ); - } -} - -class _StatusDivider extends StatelessWidget { - const _StatusDivider(); - - @override - Widget build(BuildContext context) { - return Container( - width: 1, - height: 14, - margin: const EdgeInsets.symmetric(horizontal: 8), - color: Theme.of(context).colorScheme.outlineVariant, - ); - } -} - -/// Dunne verticale scheiding tussen groepen AppBar-knoppen. -class _ActionsDivider extends StatelessWidget { - const _ActionsDivider(); - - @override - Widget build(BuildContext context) { - return Container( - width: 1, - height: 20, - margin: const EdgeInsets.symmetric(horizontal: 6), - color: Colors.white24, - ); - } -} - -class _ResizableDivider extends StatefulWidget { - final ValueChanged onDrag; - - const _ResizableDivider({required this.onDrag}); - - @override - State<_ResizableDivider> createState() => _ResizableDividerState(); -} - -class _ResizableDividerState extends State<_ResizableDivider> { - static const double _keyboardStep = 24; - - bool _hovered = false; - bool _dragging = false; - bool _focused = false; - - KeyEventResult _onKeyEvent(FocusNode node, KeyEvent event) { - if (event is KeyUpEvent) return KeyEventResult.ignored; - if (event.logicalKey == LogicalKeyboardKey.arrowLeft) { - widget.onDrag(-_keyboardStep); - return KeyEventResult.handled; - } - if (event.logicalKey == LogicalKeyboardKey.arrowRight) { - widget.onDrag(_keyboardStep); - return KeyEventResult.handled; - } - return KeyEventResult.ignored; - } - - @override - Widget build(BuildContext context) { - final l10n = context.l10n; - final active = _hovered || _dragging || _focused; - // Keyboard-operable (WCAG 2.1.1): the divider is focusable, arrow keys - // move it, and focus is shown with the same highlight as hovering - // (WCAG 2.4.7). Screen readers see it as an adjustable element. - return Focus( - onKeyEvent: _onKeyEvent, - onFocusChange: (focused) => setState(() => _focused = focused), - child: Semantics( - slider: true, - label: l10n.d('Breedte van het slidepaneel'), - hint: l10n.d('Pijltjestoetsen passen de breedte aan'), - onIncrease: () => widget.onDrag(_keyboardStep), - onDecrease: () => widget.onDrag(-_keyboardStep), - child: MouseRegion( - cursor: SystemMouseCursors.resizeColumn, - onEnter: (_) => setState(() => _hovered = true), - onExit: (_) => setState(() => _hovered = false), - child: GestureDetector( - behavior: HitTestBehavior.translucent, - onHorizontalDragStart: (_) => setState(() => _dragging = true), - onHorizontalDragEnd: (_) => setState(() => _dragging = false), - onHorizontalDragCancel: () => setState(() => _dragging = false), - onHorizontalDragUpdate: (details) => - widget.onDrag(details.delta.dx), - child: Tooltip( - message: l10n.d( - 'Sleep om de slide-preview breder of smaller te maken', - ), - child: SizedBox( - width: 9, - child: Center( - child: AnimatedContainer( - duration: const Duration(milliseconds: 90), - width: active ? 3 : 1, - color: active - ? Theme.of(context).colorScheme.secondary - : Theme.of(context).colorScheme.outlineVariant, - ), - ), - ), - ), - ), - ), - ), - ); - } -} - -/// TLP-classificatie als altijd zichtbare, direct instelbare chip in de -/// AppBar-titel. Toont de huidige status in de officiële TLP-kleur en opent -/// bij klikken een keuzelijst met alle niveaus (incl. "Geen"). -class _TlpChip extends StatelessWidget { - final TlpLevel tlp; - final ValueChanged onSelected; - - const _TlpChip({required this.tlp, required this.onSelected}); - - @override - Widget build(BuildContext context) { - final l10n = context.l10n; - final isSet = tlp != TlpLevel.none; - final fg = Color(tlp.foreground); - - final child = Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 5), - decoration: BoxDecoration( - color: isSet ? Colors.black : Colors.transparent, - borderRadius: BorderRadius.circular(6), - border: Border.all( - color: isSet ? fg.withValues(alpha: 0.7) : Colors.white24, - ), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (!isSet) - const Icon(Icons.shield_outlined, size: 14, color: Colors.white70), - if (!isSet) const SizedBox(width: 5), - Text( - isSet ? tlp.label : 'TLP', - style: TextStyle( - color: isSet ? fg : Colors.white70, - fontSize: 11.5, - fontWeight: FontWeight.w700, - fontFamily: 'monospace', - fontFamilyFallback: const ['Menlo', 'Consolas', 'Courier New'], - letterSpacing: 0.3, - ), - ), - Icon( - Icons.arrow_drop_down, - size: 16, - color: isSet ? fg : Colors.white54, - ), - ], - ), - ); - - return PopupMenuButton( - tooltip: l10n.d('TLP-classificatie (Traffic Light Protocol)'), - position: PopupMenuPosition.under, - onSelected: onSelected, - itemBuilder: (_) => [ - for (final level in TlpLevel.values) - PopupMenuItem( - value: level, - child: Row( - children: [ - Container( - width: 14, - height: 14, - decoration: BoxDecoration( - color: level == TlpLevel.none - ? Colors.transparent - : Color(level.foreground), - border: Border.all(color: const Color(0xFF94A3B8)), - borderRadius: BorderRadius.circular(3), - ), - ), - const SizedBox(width: 10), - Text(level == TlpLevel.none ? l10n.d('Geen') : level.label), - if (level == tlp) ...[ - const SizedBox(width: 12), - const Spacer(), - const Icon(Icons.check, size: 16, color: Color(0xFF475569)), - ], - ], - ), - ), - ], - child: child, - ); - } -} diff --git a/lib/widgets/dialogs/consent_dialog.dart b/lib/widgets/dialogs/consent_dialog.dart index 0042b5b..11b7033 100644 --- a/lib/widgets/dialogs/consent_dialog.dart +++ b/lib/widgets/dialogs/consent_dialog.dart @@ -41,9 +41,7 @@ class _ConsentDialogState extends ConsumerState { padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: theme.colorScheme.surface, - border: Border.all( - color: theme.colorScheme.outlineVariant, - ), + border: Border.all(color: theme.colorScheme.outlineVariant), borderRadius: BorderRadius.circular(8), ), child: Column( @@ -115,8 +113,9 @@ class _ConsentDialogState extends ConsumerState { Container( padding: const EdgeInsets.all(12), decoration: BoxDecoration( - color: theme.colorScheme.primaryContainer - .withValues(alpha: 0.2), + color: theme.colorScheme.primaryContainer.withValues( + alpha: 0.2, + ), border: Border.all( color: theme.colorScheme.primary.withValues(alpha: 0.3), ), diff --git a/lib/widgets/dialogs/image_carousel_picker.dart b/lib/widgets/dialogs/image_carousel_picker.dart index 44604f7..7133fd3 100644 --- a/lib/widgets/dialogs/image_carousel_picker.dart +++ b/lib/widgets/dialogs/image_carousel_picker.dart @@ -9,6 +9,7 @@ import '../../services/image_dedup_service.dart'; import '../../services/image_reference_service.dart'; import '../../services/image_service.dart'; import '../../l10n/app_localizations.dart'; +import '../../utils/log.dart'; /// Resultaat van de afbeeldingencarousel. class ImagePickResult { @@ -169,7 +170,9 @@ class _ImageCarouselPickerState extends State { if (_exts.contains(ext)) found.add(e.path); } } - } catch (_) {} + } catch (e) { + logWarning('_ImageCarouselPickerState._loadImages: directory scan', e); + } } // Stat each file exactly once (instead of repeatedly inside the sort @@ -179,7 +182,8 @@ class _ImageCarouselPickerState extends State { DateTime modified; try { modified = File(path).statSync().modified; - } catch (_) { + } catch (e) { + logWarning('_ImageCarouselPickerState._loadImages: statSync', e); modified = DateTime.fromMillisecondsSinceEpoch(0); } withTimes.add((path, modified)); @@ -323,9 +327,10 @@ class _ImageCarouselPickerState extends State { // bestand staan waar de meeste slides (open of niet) naar wijzen. Open // decks worden via usageOf geteld en hier overgeslagen. final deckFiles = await refs.findDeckFiles(widget.searchPaths); - final diskCounts = await refs.countReferences(_withoutOpenDecks(deckFiles), [ - for (final group in groups) ...group, - ]); + final diskCounts = await refs.countReferences( + _withoutOpenDecks(deckFiles), + [for (final group in groups) ...group], + ); if (!mounted) return; final plan = <({String keeper, List remove})>[ @@ -359,13 +364,13 @@ class _ImageCarouselPickerState extends State { // Keeper eerst, zodat zijn eigen tekst vooraan blijft staan. final ordered = [entry.keeper, ...entry.remove]; final captions = [ - for (final path in ordered) await widget.captionService.getCaption(path), + for (final path in ordered) + await widget.captionService.getCaption(path), ]; final mergedCaption = dedup.mergeMetadata(captions); - final mergedDescription = dedup.mergeMetadata( - [for (final path in ordered) _descriptions[path]], - separator: ', ', - ); + final mergedDescription = dedup.mergeMetadata([ + for (final path in ordered) _descriptions[path], + ], separator: ', '); if (mergedCaption.isNotEmpty) { await widget.captionService.saveCaption(entry.keeper, mergedCaption); } @@ -390,7 +395,9 @@ class _ImageCarouselPickerState extends State { try { final file = File(path); if (file.existsSync()) await file.delete(); - } catch (_) {} + } catch (e) { + logWarning('_ImageCarouselPickerState._dedupe: delete file', e); + } await widget.captionService.saveCaption(path, ''); await widget.descriptionService.removeDescription(path); _descriptions.remove(path); @@ -426,9 +433,9 @@ class _ImageCarouselPickerState extends State { : updatedDeckFiles.length == 1 ? ' · ${l10n.d('1 presentatiebestand bijgewerkt.')}' : ' · ${updatedDeckFiles.length} ${l10n.d('presentatiebestanden bijgewerkt.')}'; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('$removedText$filesText')), - ); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('$removedText$filesText'))); } Future _showDedupeDialog( @@ -719,11 +726,18 @@ class _ImageCarouselPickerState extends State { final confirmed = await _showDeleteDialog(path, usages, slideCount); if (confirmed != true) return; + var deleted = false; try { final file = File(path); if (file.existsSync()) await file.delete(); - } catch (_) {} - // Drop the sidecar metadata too. + deleted = true; + } catch (e) { + debugPrint('Kon afbeelding niet verwijderen: $e'); + } + // Only drop the sidecar metadata and the carousel entry once the file is + // actually gone; otherwise the image would disappear from the UI while it + // still exists on disk, having silently lost its caption/description. + if (!deleted) return; await widget.captionService.saveCaption(path, ''); await widget.descriptionService.removeDescription(path); @@ -1083,7 +1097,9 @@ class _ImageCarouselPickerState extends State { duration: const Duration(milliseconds: 150), padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), decoration: BoxDecoration( - color: _untaggedOnly ? const Color(0xFF1D2433) : const Color(0xFF0D1117), + color: _untaggedOnly + ? const Color(0xFF1D2433) + : const Color(0xFF0D1117), borderRadius: BorderRadius.circular(9), border: Border.all( color: _untaggedOnly @@ -2053,7 +2069,9 @@ class _FileSizeState extends State<_FileSize> { ? '${mb.toStringAsFixed(1)} MB' : '${kb.toStringAsFixed(0)} KB'; if (mounted) setState(() => _size = label); - } catch (_) {} + } catch (e) { + logWarning('_FileSizeState._load: compute size label', e); + } } @override diff --git a/lib/widgets/dialogs/settings_dialog.dart b/lib/widgets/dialogs/settings_dialog.dart index d292b80..36c68b3 100644 --- a/lib/widgets/dialogs/settings_dialog.dart +++ b/lib/widgets/dialogs/settings_dialog.dart @@ -1648,7 +1648,10 @@ class _SettingsDialogState extends ConsumerState { children: [ Text( l10n.d('U hebt al toegestemd in het gebruik van OciDeck.'), - style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w500), + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + ), ), const SizedBox(height: 8), Text( diff --git a/lib/widgets/editors/_editor_field.dart b/lib/widgets/editors/_editor_field.dart index 87177f8..112c954 100644 --- a/lib/widgets/editors/_editor_field.dart +++ b/lib/widgets/editors/_editor_field.dart @@ -270,9 +270,7 @@ class ImagePickerBar extends ConsumerWidget { } if (slide.imagePath2.isNotEmpty && resolve(slide.imagePath2) == target) { - updated = updated.copyWith( - imagePath2: replacement(slide.imagePath2), - ); + updated = updated.copyWith(imagePath2: replacement(slide.imagePath2)); } if (!identical(updated, slide)) notifier.updateSlide(i, updated); } diff --git a/lib/widgets/panels/slide_list_panel.dart b/lib/widgets/panels/slide_list_panel.dart index ea7f107..961b3bd 100644 --- a/lib/widgets/panels/slide_list_panel.dart +++ b/lib/widgets/panels/slide_list_panel.dart @@ -15,6 +15,7 @@ import '../../services/slide_rasterizer.dart'; import '../../state/slide_clipboard_provider.dart'; import '../../theme/app_theme.dart'; import '../../l10n/app_localizations.dart'; +import '../../utils/log.dart'; import '../dialogs/add_slide_dialog.dart'; import '../dialogs/import_slides_dialog.dart'; import '../dialogs/slide_finder_dialog.dart'; @@ -216,7 +217,9 @@ class _SlideListPanelState extends ConsumerState { tlp: deck.tlp, ); if (images.isNotEmpty) bytes = images.first; - } catch (_) {} + } catch (e) { + logWarning('_SlideListPanelState._copySlideAsImage: rasterize slide', e); + } if (!mounted) return; final ok = bytes != null && await ImageService().copyImageBytesToClipboard(bytes); diff --git a/lib/widgets/presentation/audience_window.dart b/lib/widgets/presentation/audience_window.dart index a95b69c..4fc5ddb 100644 --- a/lib/widgets/presentation/audience_window.dart +++ b/lib/widgets/presentation/audience_window.dart @@ -6,6 +6,7 @@ import '../../models/deck.dart'; import '../../models/settings.dart'; import '../../models/slide.dart'; import '../../services/markdown_service.dart'; +import '../../utils/log.dart'; import '../../utils/url_launcher_util.dart'; import '../slides/slide_preview.dart'; import 'annotation_overlay.dart'; @@ -133,7 +134,12 @@ class _AudienceWindowAppState extends State { try { final self = await WindowController.fromCurrentEngine(); await self.close(); - } catch (_) {} + } catch (e) { + logWarning( + '_AudienceWindowAppState._onPresenterCall: close window', + e, + ); + } } return null; } diff --git a/lib/widgets/presentation/fullscreen_presenter.dart b/lib/widgets/presentation/fullscreen_presenter.dart index b0cc448..57a0fbe 100644 --- a/lib/widgets/presentation/fullscreen_presenter.dart +++ b/lib/widgets/presentation/fullscreen_presenter.dart @@ -13,6 +13,7 @@ import '../../models/deck.dart'; import '../../models/settings.dart'; import '../../models/slide.dart'; import '../../services/markdown_service.dart'; +import '../../utils/log.dart'; import '../../utils/url_launcher_util.dart'; import '../../l10n/app_localizations.dart'; import '../slides/inline_markdown.dart'; @@ -73,7 +74,8 @@ class FullscreenPresenter extends StatefulWidget { try { final displays = await screenRetriever.getAllDisplays(); displayCount = displays.length; - } catch (_) { + } catch (e) { + logWarning('FullscreenPresenter.present: display detection failed', e); displayCount = 0; } } @@ -203,7 +205,11 @@ class FullscreenPresenter extends StatefulWidget { WindowConfiguration(arguments: argument, hiddenAtLaunch: true), ); await audience.coverScreen(external: true); - } catch (_) { + } catch (e) { + logError( + 'FullscreenPresenter.showDualScreen: audience window setup failed', + e, + ); audience = null; } @@ -283,7 +289,8 @@ bool autoAdvanceWaitsForMedia(Slide slide) { Future _wakeLockEnabled() async { try { return await WakelockPlus.enabled; - } catch (_) { + } catch (e) { + logWarning('fullscreen_presenter._wakeLockEnabled: query failed', e); return false; } } @@ -291,7 +298,8 @@ Future _wakeLockEnabled() async { Future _enableWakeLock() async { try { await WakelockPlus.enable(); - } catch (_) { + } catch (e) { + logWarning('fullscreen_presenter._enableWakeLock: enable failed', e); // Best-effort: unsupported platforms should not interrupt presenting. } } @@ -303,7 +311,8 @@ Future _restoreWakeLock(bool enabledBeforePresentation) async { } else { await WakelockPlus.disable(); } - } catch (_) { + } catch (e) { + logWarning('fullscreen_presenter._restoreWakeLock: restore failed', e); // Best-effort cleanup. } } @@ -560,10 +569,7 @@ class _FullscreenPresenterState extends State { } _lastInkLiveSent = now; audienceChannel - .invokeMethod('inkLive', { - 'index': _index, - 'stroke': stroke?.toJson(), - }) + .invokeMethod('inkLive', {'index': _index, 'stroke': stroke?.toJson()}) .catchError((_) => null); } @@ -707,7 +713,11 @@ class _FullscreenPresenterState extends State { _displays = displays; _displayIndex = current < 0 ? 0 : current; }); - } catch (_) { + } catch (e) { + logWarning( + '_FullscreenPresenterState._loadDisplays: screen detection failed', + e, + ); // Screen detection is best-effort; presenting should still work. } } @@ -724,7 +734,11 @@ class _FullscreenPresenterState extends State { ); await windowManager.setFullScreen(true); if (mounted) setState(() => _displayIndex = index); - } catch (_) { + } catch (e) { + logError( + '_FullscreenPresenterState._moveToDisplay: moving window to display failed', + e, + ); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( @@ -1425,6 +1439,10 @@ class _FullscreenPresenterState extends State { // Annotatielaag bovenop de dia. Laat klikken door wanneer er // geen gereedschap actief is (zodat tikken blijft doorbladeren). AnnotationLayer( + // Keyed by slide so a slide change (e.g. auto-advance) while a + // stroke is in progress resets the layer instead of committing + // the half-drawn stroke onto the next slide. + key: ValueKey(slide.id), strokes: _currentStrokes, tool: _tool, color: _inkColor, diff --git a/lib/widgets/shell/shell_actions.dart b/lib/widgets/shell/shell_actions.dart new file mode 100644 index 0000000..18e11d5 --- /dev/null +++ b/lib/widgets/shell/shell_actions.dart @@ -0,0 +1,171 @@ +// Part of the app_shell library — see ../app_shell.dart. +// Split out for navigability; all imports live in the main library file. +part of '../app_shell.dart'; + +/// Open the search-based presentation picker and load the chosen file +/// (optionally jumping to a matched slide). +Future _openWithSearch( + BuildContext context, + WidgetRef ref, + String? initialDirectory, +) async { + final settings = ref.read(settingsProvider); + final result = await OpenPresentationDialog.show( + context, + fileService: ref.read(fileServiceProvider), + initialDirectory: initialDirectory ?? settings.homeDirectory, + ); + if (result == null) return; + await ref + .read(tabsProvider.notifier) + .openFileByPath(result.path, selectIndex: result.slideIndex); +} + +/// Vraag een URL op om een presentatie (pakket of markdown) op te halen. +Future _showUrlDialog(BuildContext context) { + final l10n = context.l10n; + final controller = TextEditingController(); + return showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: Text(l10n.d('Importeren via URL')), + content: SizedBox( + width: 460, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + l10n.d( + 'Plak de link naar een .ocideck-pakket of een Marp-markdownbestand.', + ), + style: const TextStyle(fontSize: 12, color: Color(0xFF64748B)), + ), + const SizedBox(height: 12), + TextField( + controller: controller, + autofocus: true, + keyboardType: TextInputType.url, + decoration: const InputDecoration( + hintText: 'https://…', + prefixIcon: Icon(Icons.link, size: 18), + isDense: true, + border: OutlineInputBorder(), + ), + onSubmitted: (v) => Navigator.pop(ctx, v), + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx), + child: Text(l10n.t('cancel')), + ), + ElevatedButton.icon( + onPressed: () => Navigator.pop(ctx, controller.text), + icon: const Icon(Icons.download, size: 16), + label: Text(l10n.d('Ophalen')), + ), + ], + ), + ); +} + +List _imageSearchPaths(String? projectPath, String? homeDirectory) { + final projectImagesPath = projectPath == null + ? null + : p.join(projectPath, 'images'); + return [?projectImagesPath, ?projectPath, ?homeDirectory]; +} + +String? _resolveImagePath(String path, String? projectPath) { + if (path.isEmpty) return null; + if (p.isAbsolute(path) || projectPath == null) return path; + return p.join(projectPath, path); +} + +List _imageUsages(WidgetRef ref, String absolutePath) { + final target = p.normalize(absolutePath); + final usages = []; + for (final tab in ref.read(tabsProvider).tabs) { + final deck = tab.deckNotifier.currentState.deck; + if (deck == null) continue; + for (var i = 0; i < deck.slides.length; i++) { + final slide = deck.slides[i]; + for (final candidate in [slide.imagePath, slide.imagePath2]) { + if (candidate.isEmpty) continue; + final resolved = p.normalize( + p.isAbsolute(candidate) + ? candidate + : p.join(deck.projectPath ?? '', candidate), + ); + if (resolved == target) { + usages.add('${tab.label} · slide ${i + 1}'); + break; + } + } + } + } + return usages; +} + +/// Wijs in alle open decks elke slideverwijzing naar [fromAbsolute] om naar +/// [toAbsolute]. Gebruikt door de afbeeldingenbibliotheek wanneer een md5- +/// duplicaat wordt opgeruimd, zodat slides het behouden bestand blijven tonen. +Future _replaceImageUsages( + WidgetRef ref, + String fromAbsolute, + String toAbsolute, +) async { + final target = p.normalize(fromAbsolute); + for (final tab in ref.read(tabsProvider).tabs) { + final notifier = tab.deckNotifier; + final deck = notifier.currentState.deck; + if (deck == null) continue; + final projectPath = deck.projectPath ?? ''; + + String resolve(String candidate) => p.normalize( + p.isAbsolute(candidate) ? candidate : p.join(projectPath, candidate), + ); + // Blijf relatief opslaan als de slide dat al deed en het nieuwe pad + // binnen het project ligt; anders absoluut. + String replacement(String candidate) { + if (p.isAbsolute(candidate) || projectPath.isEmpty) return toAbsolute; + return p.isWithin(projectPath, toAbsolute) + ? p.relative(toAbsolute, from: projectPath) + : toAbsolute; + } + + for (var i = 0; i < deck.slides.length; i++) { + final slide = deck.slides[i]; + var updated = slide; + if (slide.imagePath.isNotEmpty && resolve(slide.imagePath) == target) { + updated = updated.copyWith(imagePath: replacement(slide.imagePath)); + } + if (slide.imagePath2.isNotEmpty && resolve(slide.imagePath2) == target) { + updated = updated.copyWith(imagePath2: replacement(slide.imagePath2)); + } + if (!identical(updated, slide)) notifier.updateSlide(i, updated); + } + } +} + +List _slidesForPresentationOrExport(Deck deck) { + // Drop skipped slides and slides whose TLP classification is stricter than + // the level chosen for this presentation/export. + final slides = deck.slides + .where((s) => !s.skipped && slideVisibleAtTlp(s, deck.tlp)) + .toList(); + final closingMarkdown = deck.themeProfile.closingSlideMarkdown.trim(); + if (deck.themeProfile.closingSlideEnabled && closingMarkdown.isNotEmpty) { + slides.add( + Slide.create( + SlideType.freeMarkdown, + ).copyWith(customMarkdown: closingMarkdown), + ); + } + return slides; +} + +// ── App shell ───────────────────────────────────────────────────────────────── diff --git a/lib/widgets/shell/shell_overlays.dart b/lib/widgets/shell/shell_overlays.dart new file mode 100644 index 0000000..16d4477 --- /dev/null +++ b/lib/widgets/shell/shell_overlays.dart @@ -0,0 +1,139 @@ +// Part of the app_shell library — see ../app_shell.dart. +// Split out for navigability; all imports live in the main library file. +part of '../app_shell.dart'; + +/// Visuele hint terwijl bestanden boven het venster zweven. +class _DropOverlay extends StatelessWidget { + const _DropOverlay(); + + @override + Widget build(BuildContext context) { + return Positioned.fill( + child: IgnorePointer( + child: Container( + color: const Color(0xFF1C2B47).withValues(alpha: 0.55), + alignment: Alignment.center, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 22), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(14), + border: Border.all(color: const Color(0xFF60A5FA), width: 2), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.file_download_outlined, + size: 40, + color: Color(0xFF2563EB), + ), + const SizedBox(height: 10), + Text( + context.l10n.d('Laat los om toe te voegen'), + style: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.w600, + color: Color(0xFF1E293B), + ), + ), + const SizedBox(height: 4), + Text( + context.l10n.d( + 'Afbeeldingen → nieuwe slides · .md / .ocideck → openen', + ), + style: const TextStyle( + fontSize: 12, + color: Color(0xFF64748B), + ), + ), + ], + ), + ), + ), + ), + ); + } +} + +// ── Tab bar ─────────────────────────────────────────────────────────────────── + +class _ResizableDivider extends StatefulWidget { + final ValueChanged onDrag; + + const _ResizableDivider({required this.onDrag}); + + @override + State<_ResizableDivider> createState() => _ResizableDividerState(); +} + +class _ResizableDividerState extends State<_ResizableDivider> { + static const double _keyboardStep = 24; + + bool _hovered = false; + bool _dragging = false; + bool _focused = false; + + KeyEventResult _onKeyEvent(FocusNode node, KeyEvent event) { + if (event is KeyUpEvent) return KeyEventResult.ignored; + if (event.logicalKey == LogicalKeyboardKey.arrowLeft) { + widget.onDrag(-_keyboardStep); + return KeyEventResult.handled; + } + if (event.logicalKey == LogicalKeyboardKey.arrowRight) { + widget.onDrag(_keyboardStep); + return KeyEventResult.handled; + } + return KeyEventResult.ignored; + } + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + final active = _hovered || _dragging || _focused; + // Keyboard-operable (WCAG 2.1.1): the divider is focusable, arrow keys + // move it, and focus is shown with the same highlight as hovering + // (WCAG 2.4.7). Screen readers see it as an adjustable element. + return Focus( + onKeyEvent: _onKeyEvent, + onFocusChange: (focused) => setState(() => _focused = focused), + child: Semantics( + slider: true, + label: l10n.d('Breedte van het slidepaneel'), + hint: l10n.d('Pijltjestoetsen passen de breedte aan'), + onIncrease: () => widget.onDrag(_keyboardStep), + onDecrease: () => widget.onDrag(-_keyboardStep), + child: MouseRegion( + cursor: SystemMouseCursors.resizeColumn, + onEnter: (_) => setState(() => _hovered = true), + onExit: (_) => setState(() => _hovered = false), + child: GestureDetector( + behavior: HitTestBehavior.translucent, + onHorizontalDragStart: (_) => setState(() => _dragging = true), + onHorizontalDragEnd: (_) => setState(() => _dragging = false), + onHorizontalDragCancel: () => setState(() => _dragging = false), + onHorizontalDragUpdate: (details) => + widget.onDrag(details.delta.dx), + child: Tooltip( + message: l10n.d( + 'Sleep om de slide-preview breder of smaller te maken', + ), + child: SizedBox( + width: 9, + child: Center( + child: AnimatedContainer( + duration: const Duration(milliseconds: 90), + width: active ? 3 : 1, + color: active + ? Theme.of(context).colorScheme.secondary + : Theme.of(context).colorScheme.outlineVariant, + ), + ), + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/widgets/shell/status_bar.dart b/lib/widgets/shell/status_bar.dart new file mode 100644 index 0000000..17ca208 --- /dev/null +++ b/lib/widgets/shell/status_bar.dart @@ -0,0 +1,316 @@ +// Part of the app_shell library — see ../app_shell.dart. +// Split out for navigability; all imports live in the main library file. +part of '../app_shell.dart'; + +class _DeckStatusBar extends StatelessWidget { + final Deck deck; + final DeckState deckState; + final String? exportDirectory; + final Future Function() onSave; + final VoidCallback? onExport; + final String exportTooltip; + + const _DeckStatusBar({ + required this.deck, + required this.deckState, + required this.exportDirectory, + required this.onSave, + required this.onExport, + required this.exportTooltip, + }); + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + final skipped = deck.slides.where((s) => s.skipped).length; + final fileLabel = deckState.filePath == null + ? l10n.t('notSavedYet') + : p.basename(deckState.filePath!); + final saveLabel = deckState.isDirty ? l10n.t('unsaved') : l10n.t('saved'); + final exportLabel = exportDirectory == null + ? l10n.t('exportNextToDeck') + : '${l10n.t('exportFolder')}: ${p.basename(exportDirectory!)}'; + + final theme = Theme.of(context); + return Material( + color: theme.colorScheme.surface, + child: Container( + height: 30, + padding: const EdgeInsets.symmetric(horizontal: 10), + decoration: BoxDecoration( + border: Border( + top: BorderSide(color: theme.colorScheme.outlineVariant), + ), + ), + child: Row( + children: [ + _StatusAction( + icon: deckState.isDirty + ? Icons.radio_button_checked + : Icons.check_circle_outline, + label: saveLabel, + tooltip: deckState.isDirty + ? l10n.t('unsavedChanges') + : l10n.t('noUnsavedChanges'), + color: deckState.isDirty + ? const Color(0xFFD97706) + : const Color(0xFF15803D), + onTap: () => onSave(), + ), + const _StatusDivider(), + _StatusItem( + icon: Icons.description_outlined, + label: fileLabel, + tooltip: deckState.filePath ?? l10n.t('noFileYet'), + ), + const _StatusDivider(), + _StatusItem( + icon: Icons.slideshow_outlined, + label: skipped == 0 + ? '${deck.slides.length} ${l10n.t('slides')}' + : '${deck.slides.length} ${l10n.t('slides')} · $skipped ${l10n.t('skipped')}', + tooltip: skipped == 0 + ? l10n.t('allSlidesIncluded') + : '$skipped ${l10n.t('skippedSlidesExcluded')}', + color: skipped == 0 ? null : const Color(0xFF8A6D3B), + ), + const _StatusDivider(), + _StatusItem( + icon: Icons.palette_outlined, + label: deck.themeProfile.name, + tooltip: '${l10n.t('styleProfile')}: ${deck.themeProfile.name}', + ), + if (deck.tlp != TlpLevel.none) ...[ + const _StatusDivider(), + _StatusItem( + icon: Icons.shield_outlined, + label: deck.tlp.label, + tooltip: '${l10n.t('classification')}: ${deck.tlp.label}', + color: Color(deck.tlp.foreground), + ), + ], + const Spacer(), + _StatusItem( + icon: Icons.folder_outlined, + label: exportLabel, + tooltip: exportDirectory ?? l10n.t('exportsNextToDeck'), + ), + const SizedBox(width: 6), + _StatusAction( + icon: Icons.upload_file_outlined, + label: l10n.t('export'), + tooltip: exportTooltip, + onTap: onExport, + ), + ], + ), + ), + ); + } +} + +class _StatusItem extends StatelessWidget { + final IconData icon; + final String label; + final String tooltip; + final Color? color; + + const _StatusItem({ + required this.icon, + required this.label, + required this.tooltip, + this.color, + }); + + @override + Widget build(BuildContext context) { + final fg = color ?? Theme.of(context).colorScheme.onSurfaceVariant; + return Tooltip( + message: tooltip, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 13, color: fg), + const SizedBox(width: 4), + ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 210), + child: Text( + label, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 11, + color: fg, + fontWeight: color == null ? FontWeight.normal : FontWeight.w600, + ), + ), + ), + ], + ), + ); + } +} + +class _StatusAction extends StatelessWidget { + final IconData icon; + final String label; + final String tooltip; + final Color? color; + final VoidCallback? onTap; + + const _StatusAction({ + required this.icon, + required this.label, + required this.tooltip, + this.color, + this.onTap, + }); + + @override + Widget build(BuildContext context) { + final enabled = onTap != null; + final fg = enabled + ? (color ?? Theme.of(context).colorScheme.secondary) + : Theme.of(context).disabledColor; + return Tooltip( + message: tooltip, + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(4), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 3), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 13, color: fg), + const SizedBox(width: 4), + Text( + label, + style: TextStyle( + fontSize: 11, + color: fg, + fontWeight: enabled ? FontWeight.w600 : FontWeight.normal, + ), + ), + ], + ), + ), + ), + ); + } +} + +class _StatusDivider extends StatelessWidget { + const _StatusDivider(); + + @override + Widget build(BuildContext context) { + return Container( + width: 1, + height: 14, + margin: const EdgeInsets.symmetric(horizontal: 8), + color: Theme.of(context).colorScheme.outlineVariant, + ); + } +} + +/// Dunne verticale scheiding tussen groepen AppBar-knoppen. +class _ActionsDivider extends StatelessWidget { + const _ActionsDivider(); + + @override + Widget build(BuildContext context) { + return Container( + width: 1, + height: 20, + margin: const EdgeInsets.symmetric(horizontal: 6), + color: Colors.white24, + ); + } +} + +/// TLP-classificatie als altijd zichtbare, direct instelbare chip in de +/// AppBar-titel. Toont de huidige status in de officiële TLP-kleur en opent +/// bij klikken een keuzelijst met alle niveaus (incl. "Geen"). +class _TlpChip extends StatelessWidget { + final TlpLevel tlp; + final ValueChanged onSelected; + + const _TlpChip({required this.tlp, required this.onSelected}); + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + final isSet = tlp != TlpLevel.none; + final fg = Color(tlp.foreground); + + final child = Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 5), + decoration: BoxDecoration( + color: isSet ? Colors.black : Colors.transparent, + borderRadius: BorderRadius.circular(6), + border: Border.all( + color: isSet ? fg.withValues(alpha: 0.7) : Colors.white24, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (!isSet) + const Icon(Icons.shield_outlined, size: 14, color: Colors.white70), + if (!isSet) const SizedBox(width: 5), + Text( + isSet ? tlp.label : 'TLP', + style: TextStyle( + color: isSet ? fg : Colors.white70, + fontSize: 11.5, + fontWeight: FontWeight.w700, + fontFamily: 'monospace', + fontFamilyFallback: const ['Menlo', 'Consolas', 'Courier New'], + letterSpacing: 0.3, + ), + ), + Icon( + Icons.arrow_drop_down, + size: 16, + color: isSet ? fg : Colors.white54, + ), + ], + ), + ); + + return PopupMenuButton( + tooltip: l10n.d('TLP-classificatie (Traffic Light Protocol)'), + position: PopupMenuPosition.under, + onSelected: onSelected, + itemBuilder: (_) => [ + for (final level in TlpLevel.values) + PopupMenuItem( + value: level, + child: Row( + children: [ + Container( + width: 14, + height: 14, + decoration: BoxDecoration( + color: level == TlpLevel.none + ? Colors.transparent + : Color(level.foreground), + border: Border.all(color: const Color(0xFF94A3B8)), + borderRadius: BorderRadius.circular(3), + ), + ), + const SizedBox(width: 10), + Text(level == TlpLevel.none ? l10n.d('Geen') : level.label), + if (level == tlp) ...[ + const SizedBox(width: 12), + const Spacer(), + const Icon(Icons.check, size: 16, color: Color(0xFF475569)), + ], + ], + ), + ), + ], + child: child, + ); + } +} diff --git a/lib/widgets/shell/tab_bar.dart b/lib/widgets/shell/tab_bar.dart new file mode 100644 index 0000000..d864fc8 --- /dev/null +++ b/lib/widgets/shell/tab_bar.dart @@ -0,0 +1,167 @@ +// Part of the app_shell library — see ../app_shell.dart. +// Split out for navigability; all imports live in the main library file. +part of '../app_shell.dart'; + +class _AppTabBar extends StatelessWidget { + final TabsState tabsState; + final ValueChanged onSelect; + final ValueChanged onClose; + final VoidCallback onAdd; + + const _AppTabBar({ + required this.tabsState, + required this.onSelect, + required this.onClose, + required this.onAdd, + }); + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + final palette = Theme.of(context).extension()!; + return Container( + height: 36, + color: palette.panel, + child: Row( + children: [ + Expanded( + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: [ + for (int i = 0; i < tabsState.tabs.length; i++) + _TabChip( + tab: tabsState.tabs[i], + isActive: i == tabsState.clampedIndex, + showClose: + tabsState.tabs.length > 1 || tabsState.tabs[i].isOpen, + panelText: palette.panelText, + accent: Theme.of(context).colorScheme.secondary, + onTap: () => onSelect(i), + onClose: () => onClose(i), + ), + ], + ), + ), + ), + Tooltip( + message: l10n.t('newTab'), + child: InkWell( + onTap: onAdd, + child: SizedBox( + width: 36, + height: 36, + child: Icon( + Icons.add, + size: 16, + color: palette.panelText.withValues(alpha: 0.55), + ), + ), + ), + ), + ], + ), + ); + } +} + +class _TabChip extends StatelessWidget { + final TabInfo tab; + final bool isActive; + final bool showClose; + final VoidCallback onTap; + final VoidCallback onClose; + final Color panelText; + final Color accent; + + const _TabChip({ + required this.tab, + required this.isActive, + required this.showClose, + required this.onTap, + required this.onClose, + required this.panelText, + required this.accent, + }); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + child: Container( + constraints: const BoxConstraints(minWidth: 80, maxWidth: 200), + height: 36, + decoration: BoxDecoration( + color: isActive + ? panelText.withValues(alpha: 0.12) + : Colors.transparent, + border: Border( + bottom: BorderSide( + color: isActive ? accent : Colors.transparent, + width: 2, + ), + ), + ), + padding: const EdgeInsets.symmetric(horizontal: 10), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (tab.isDirty) + Container( + width: 6, + height: 6, + margin: const EdgeInsets.only(right: 5), + decoration: const BoxDecoration( + color: Colors.orangeAccent, + shape: BoxShape.circle, + ), + ), + Flexible( + child: Text( + tab.label, + style: TextStyle( + fontSize: 12, + color: isActive + ? panelText + : panelText.withValues(alpha: 0.72), + fontWeight: isActive ? FontWeight.w600 : FontWeight.normal, + ), + overflow: TextOverflow.ellipsis, + ), + ), + if (showClose) ...[ + const SizedBox(width: 4), + InkWell( + onTap: onClose, + borderRadius: BorderRadius.circular(3), + child: Padding( + padding: const EdgeInsets.all(2), + child: Icon( + Icons.close, + size: 12, + color: panelText.withValues(alpha: 0.55), + ), + ), + ), + ], + ], + ), + ), + ); + } +} + +// ── Per-tab content ─────────────────────────────────────────────────────────── + +class _TabContent extends ConsumerWidget { + const _TabContent(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final isOpen = ref.watch(deckProvider.select((s) => s.isOpen)); + if (!isOpen) return const _WelcomeScreen(); + return _MainLayout(exportService: ExportService()); + } +} + +// ── Welcome screen ──────────────────────────────────────────────────────────── diff --git a/lib/widgets/shell/welcome_screen.dart b/lib/widgets/shell/welcome_screen.dart new file mode 100644 index 0000000..5697975 --- /dev/null +++ b/lib/widgets/shell/welcome_screen.dart @@ -0,0 +1,164 @@ +// Part of the app_shell library — see ../app_shell.dart. +// Split out for navigability; all imports live in the main library file. +part of '../app_shell.dart'; + +class _WelcomeScreen extends ConsumerWidget { + const _WelcomeScreen(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final l10n = context.l10n; + final theme = Theme.of(context); + final palette = theme.extension()!; + final homeDir = ref.watch(settingsProvider.select((s) => s.homeDirectory)); + final recentFiles = ref.watch( + settingsProvider.select((s) => s.recentFiles), + ); + + return Scaffold( + backgroundColor: theme.scaffoldBackgroundColor, + body: Row( + children: [ + // ── Midden: logo + knoppen ───────────────────────────────────── + Expanded( + child: Align( + alignment: const Alignment(-0.15, 0.12), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Semantics( + label: 'De Winter Information Solutions', + image: true, + child: Image.asset( + 'assets/images/de-winter-wittegeheel.png', + width: 320, + fit: BoxFit.contain, + filterQuality: FilterQuality.high, + ), + ), + const SizedBox(height: 36), + SizedBox( + width: 220, + child: ElevatedButton.icon( + onPressed: () => _newDeck(context, ref), + icon: const Icon(Icons.add, size: 18), + label: Text(l10n.t('newPresentation')), + ), + ), + const SizedBox(height: 12), + SizedBox( + width: 220, + child: OutlinedButton.icon( + onPressed: () => _openWithSearch(context, ref, homeDir), + icon: const Icon(Icons.folder_open_outlined, size: 18), + label: Text(l10n.t('open')), + ), + ), + const SizedBox(height: 8), + TextButton.icon( + onPressed: () => SettingsDialog.show(context), + icon: const Icon(Icons.settings_outlined, size: 17), + label: Text(l10n.t('settings')), + ), + ], + ), + ), + ), + // ── Rechts: recente bestanden ────────────────────────────────── + if (recentFiles.isNotEmpty) + Container( + width: 280, + decoration: BoxDecoration( + color: theme.colorScheme.surface, + border: Border( + left: BorderSide(color: theme.colorScheme.outlineVariant), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 20, 16, 10), + child: Text( + l10n.t('recentPresentations'), + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w700, + color: palette.mutedText, + letterSpacing: 0.8, + ), + ), + ), + Expanded( + child: ListView.builder( + padding: const EdgeInsets.only(bottom: 16), + itemCount: recentFiles.length, + itemBuilder: (_, i) { + final path = recentFiles[i]; + final name = path.split('/').last.replaceAll('.md', ''); + return InkWell( + onTap: () => ref + .read(tabsProvider.notifier) + .openFileByPath(path), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 10, + ), + child: Row( + children: [ + Icon( + Icons.slideshow_outlined, + size: 16, + color: theme.colorScheme.onSurfaceVariant, + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + Text( + name, + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w500, + color: theme.colorScheme.onSurface, + ), + overflow: TextOverflow.ellipsis, + ), + Text( + path, + style: TextStyle( + fontSize: 10, + color: palette.mutedText, + ), + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + ], + ), + ), + ); + }, + ), + ), + ], + ), + ), + ], + ), + ); + } + + Future _newDeck(BuildContext context, WidgetRef ref) async { + final title = await NewDeckDialog.show(context); + if (title != null) { + ref.read(tabsProvider.notifier).newDeckInCurrentTab(title); + } + } +} + +// ── Main 2-panel layout ─────────────────────────────────────────────────────── diff --git a/lib/widgets/slides/previews/bullets_previews.dart b/lib/widgets/slides/previews/bullets_previews.dart new file mode 100644 index 0000000..7217b0c --- /dev/null +++ b/lib/widgets/slides/previews/bullets_previews.dart @@ -0,0 +1,916 @@ +// Part of the slide_preview library — see ../slide_preview.dart. +// Split out for navigability; all imports live in the main library file. +part of '../slide_preview.dart'; + +class _BulletsPreview extends StatelessWidget { + final Slide slide; + final double w; + final String font; + final ThemeProfile profile; + + const _BulletsPreview({ + required this.slide, + required this.w, + required this.font, + required this.profile, + }); + + @override + Widget build(BuildContext context) { + final pad = w * 0.07; + // Slightly tighter top/bottom margin than the side margin so short + // checklists can grow into more of the slide height instead of leaving a + // wide empty band below the text. + final vPad = w * 0.05; + final safe = slide.showLogo ? _logoSafeInsets(w, profile) : EdgeInsets.zero; + final titleSize = w * 0.042; + final subtitleSize = w * 0.030; + final bulletSize = w * 0.026; + final spacing = pad * 0.5; + final bulletGap = w * 0.006; + final bullets = slide.bullets + .where((b) => b.trimLeft().isNotEmpty) + .toList(); + final hasTitle = slide.title.isNotEmpty; + final subtitle = slide.subtitle; + final hasSubtitle = subtitle.isNotEmpty; + final showProgress = + slide.listStyle == ListStyle.checklist && + slide.showChecklistProgress && + bullets.isNotEmpty; + + final slideHeight = w * 9 / 16; + final availW = (w - pad * 2).clamp(w * 0.12, w); + // The progress chart only needs a modest, fixed slot; give all remaining + // width to the bullets so the text can grow as large (and readable) as + // possible, especially on slides with many checklist items. + final progressGap = w * 0.025; + final progressW = w * 0.34; + final textAvailW = showProgress + ? (availW - progressGap - progressW).clamp(w * 0.12, availW) + : availW; + final availH = slideHeight - (vPad + safe.top) - (vPad + safe.bottom); + // Grow (or, when needed, shrink) the text so it uses the full vertical + // space instead of leaving a large empty area below a few short bullets. + final scale = _bulletsFitScale( + availW: textAvailW, + availH: availH, + hasTitle: hasTitle, + title: slide.title, + bullets: bullets, + titleSize: titleSize, + bulletSize: bulletSize, + spacing: spacing, + bulletGap: bulletGap, + font: font, + subtitle: subtitle, + subtitleSize: subtitleSize, + maxScale: _bulletScaleCap(w, bulletSize, _kSplitBulletsMaxScale), + listStyle: slide.listStyle, + ); + + return Container( + color: _hexColor(profile.slideBackgroundColor), + child: SizedBox.expand( + child: FittedBox( + fit: BoxFit.scaleDown, + alignment: Alignment.topLeft, + child: SizedBox( + width: w, + child: Padding( + padding: EdgeInsets.fromLTRB( + pad, + vPad + safe.top, + pad, + vPad + safe.bottom, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + if (hasTitle) + _md( + context, + slide.title, + _applyFont( + font, + TextStyle( + fontSize: titleSize * scale, + fontWeight: FontWeight.bold, + color: _hexColor(profile.textColor), + ), + ), + linkColor: _hexColor(profile.accentColor), + ), + if (hasSubtitle) ...[ + SizedBox(height: spacing * scale * 0.4), + _md( + context, + subtitle, + _applyFont( + font, + TextStyle( + fontSize: subtitleSize * scale, + fontWeight: FontWeight.w600, + color: _hexColor(profile.accentColor), + ), + ), + linkColor: _hexColor(profile.accentColor), + ), + ], + if ((hasTitle || hasSubtitle) && bullets.isNotEmpty) + SizedBox(height: spacing * scale), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: _BulletListColumn( + bullets: bullets, + listStyle: slide.listStyle, + font: font, + profile: profile, + bulletSize: bulletSize, + bulletGap: bulletGap, + scale: scale, + column: 0, + ), + ), + if (showProgress) ...[ + SizedBox(width: progressGap), + SizedBox( + width: progressW, + child: Center( + child: _ChecklistProgress( + bullets: bullets, + w: w, + font: font, + profile: profile, + ), + ), + ), + ], + ], + ), + ], + ), + ), + ), + ), + ), + ); + } +} + +class _TwoBulletsPreview extends StatelessWidget { + final Slide slide; + final double w; + final String font; + final ThemeProfile profile; + + const _TwoBulletsPreview({ + required this.slide, + required this.w, + required this.font, + required this.profile, + }); + + /// One bullet column with an optional heading above it. When any column has a + /// heading, an equal-height slot is reserved in both so the bullet lists line + /// up. + Widget _bulletColumn( + BuildContext context, { + required String title, + required List bullets, + required double columnW, + required double headingSize, + required double headingSlotH, + required double headingGap, + required double bulletSize, + required double bulletGap, + required double scale, + required int column, + }) { + return SizedBox( + width: columnW, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + if (headingSlotH > 0) ...[ + SizedBox( + width: double.infinity, + height: headingSlotH, + child: title.isEmpty + ? null + : _md( + context, + title, + _applyFont( + font, + TextStyle( + fontSize: headingSize, + fontWeight: FontWeight.bold, + color: _hexColor(profile.accentColor), + ), + ), + linkColor: _hexColor(profile.accentColor), + ), + ), + SizedBox(height: headingGap), + ], + _BulletListColumn( + bullets: bullets, + listStyle: slide.listStyle, + font: font, + profile: profile, + bulletSize: bulletSize, + bulletGap: bulletGap, + scale: scale, + column: column, + ), + ], + ), + ); + } + + @override + Widget build(BuildContext context) { + final pad = w * 0.065; + // Tighter top/bottom margin than the side margin so dense columns (e.g. a + // 19-item list) can use more of the slide height and stay readable. + final vPad = w * 0.045; + final safe = slide.showLogo ? _logoSafeInsets(w, profile) : EdgeInsets.zero; + final leftBullets = slide.bullets + .where((b) => b.trimLeft().isNotEmpty) + .toList(); + final rightBullets = slide.bullets2 + .where((b) => b.trimLeft().isNotEmpty) + .toList(); + final hasTitle = slide.title.isNotEmpty; + + // On dense slides (a long column drives the shared text size down) spend + // less of the height on the title, headings and inter-item gaps so the + // list items themselves can render larger and stay readable. + final dense = math.max(leftBullets.length, rightBullets.length) > 12; + final titleSize = w * (dense ? 0.034 : 0.04); + final bulletSize = w * 0.024; + final spacing = pad * (dense ? 0.28 : 0.38); + final bulletGap = w * (dense ? 0.0036 : 0.0055); + final columnGap = w * 0.055; + + final col1Title = slide.columnTitle1.trim(); + final col2Title = slide.columnTitle2.trim(); + final hasColumnTitles = col1Title.isNotEmpty || col2Title.isNotEmpty; + final headingSize = w * (dense ? 0.023 : 0.03); + final headingGap = w * (dense ? 0.007 : 0.012); + + final slideHeight = w * 9 / 16; + final contentW = (w - pad * 2).clamp(w * 0.12, w); + final columnW = ((contentW - columnGap) / 2).clamp(w * 0.12, w); + var availH = slideHeight - (vPad + safe.top) - (vPad + safe.bottom); + if (hasTitle) { + availH -= _measureTextHeight( + slide.title, + titleSize, + contentW, + bold: true, + fontFamily: font, + ); + availH -= spacing; + } + // Reserve room for the (optional) column headings so the bullets still fit. + double headingHeight(String t) => t.isEmpty + ? 0 + : _measureTextHeight( + t, + headingSize, + columnW, + bold: true, + fontFamily: font, + ); + final maxHeadingH = math.max( + headingHeight(col1Title), + headingHeight(col2Title), + ); + if (hasColumnTitles) availH -= maxHeadingH + headingGap; + final leftScale = _bulletsFitScale( + availW: columnW, + availH: availH, + hasTitle: false, + title: '', + bullets: leftBullets, + titleSize: titleSize, + bulletSize: bulletSize, + spacing: spacing, + bulletGap: bulletGap, + font: font, + maxScale: _bulletScaleCap(w, bulletSize, _kBulletsMaxScale), + listStyle: slide.listStyle, + ); + final rightScale = _bulletsFitScale( + availW: columnW, + availH: availH, + hasTitle: false, + title: '', + bullets: rightBullets, + titleSize: titleSize, + bulletSize: bulletSize, + spacing: spacing, + bulletGap: bulletGap, + font: font, + maxScale: _bulletScaleCap(w, bulletSize, _kBulletsMaxScale), + listStyle: slide.listStyle, + ); + // Treat both columns as one composition: the busiest column determines + // the shared text size, so left and right never look typographically + // unrelated. + final columnScale = math.min(leftScale, rightScale); + + return Container( + color: _hexColor(profile.slideBackgroundColor), + child: SizedBox.expand( + child: Padding( + padding: EdgeInsets.fromLTRB( + pad, + vPad + safe.top, + pad, + vPad + safe.bottom, + ), + child: FittedBox( + fit: BoxFit.scaleDown, + alignment: Alignment.topLeft, + child: SizedBox( + width: contentW, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + if (hasTitle) + _md( + context, + slide.title, + _applyFont( + font, + TextStyle( + fontSize: titleSize, + fontWeight: FontWeight.bold, + color: _hexColor(profile.textColor), + ), + ), + linkColor: _hexColor(profile.accentColor), + ), + if (hasTitle) SizedBox(height: spacing), + if (slide.listStyle == ListStyle.checklist && + slide.showChecklistProgress && + (leftBullets.isNotEmpty || rightBullets.isNotEmpty)) ...[ + Align( + alignment: Alignment.center, + child: SizedBox( + width: contentW * 0.5, + child: _ChecklistProgress( + bullets: [...leftBullets, ...rightBullets], + w: w, + font: font, + profile: profile, + ), + ), + ), + SizedBox(height: spacing), + ], + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _bulletColumn( + context, + title: col1Title, + bullets: leftBullets, + columnW: columnW, + headingSize: headingSize, + headingSlotH: hasColumnTitles ? maxHeadingH : 0, + headingGap: headingGap, + bulletSize: bulletSize, + bulletGap: bulletGap, + scale: columnScale, + column: 0, + ), + SizedBox(width: columnGap), + _bulletColumn( + context, + title: col2Title, + bullets: rightBullets, + columnW: columnW, + headingSize: headingSize, + headingSlotH: hasColumnTitles ? maxHeadingH : 0, + headingGap: headingGap, + bulletSize: bulletSize, + bulletGap: bulletGap, + scale: columnScale, + column: 1, + ), + ], + ), + ], + ), + ), + ), + ), + ), + ); + } +} + +class _BulletsImagePreview extends StatelessWidget { + final Slide slide; + final double w; + final String? projectPath; + final String font; + final ThemeProfile profile; + + const _BulletsImagePreview({ + required this.slide, + required this.w, + this.projectPath, + required this.font, + required this.profile, + }); + + @override + Widget build(BuildContext context) { + final leftPad = w * 0.038; + final verticalPad = w * 0.042; + // Keep the gap between the text column and the image equal to the slide's + // left margin so the layout stays symmetric. + final gap = leftPad; + final safe = slide.showLogo + ? _splitTextLogoSafeInsets(w, profile) + : EdgeInsets.zero; + final imgFraction = (slide.imageSize > 0 ? slide.imageSize / 100.0 : 0.40) + .clamp(0.1, 0.70); + final imgWidth = w * imgFraction; + final bulletSize = w * 0.031; + final titleSize = w * 0.042; + final spacing = verticalPad * 0.32; + final bulletGap = w * 0.005; + final bullets = slide.bullets + .where((b) => b.trimLeft().isNotEmpty) + .toList(); + final hasTitle = slide.title.isNotEmpty; + + // The slide is always rendered 16:9, so the available area for the text + // column is fully determined by the width. Computing it directly (instead + // of via a LayoutBuilder) keeps the widget tree identical to the image + // side and avoids any layout-timing surprises. + final slideHeight = w * 9 / 16; + final availW = (w - imgWidth - gap - leftPad).clamp(w * 0.12, w); + final availH = + slideHeight - (verticalPad + safe.top) - (verticalPad + safe.bottom); + // Pick the largest font scale (capped at the design size) whose content + // still fits the available height at the full column width. This keeps the + // text as large as possible and lets it span the full width toward the + // image, instead of uniformly shrinking and leaving a wide gap. + final scale = _bulletsFitScale( + availW: availW, + availH: availH, + hasTitle: hasTitle, + title: slide.title, + bullets: bullets, + titleSize: titleSize, + bulletSize: bulletSize, + spacing: spacing, + bulletGap: bulletGap, + font: font, + maxScale: _bulletScaleCap(w, bulletSize, _kBulletsMaxScale), + listStyle: slide.listStyle, + ); + + return Container( + color: _hexColor(profile.slideBackgroundColor), + child: Stack( + children: [ + Positioned( + top: 0, + right: 0, + bottom: 0, + width: imgWidth, + child: Stack( + fit: StackFit.expand, + children: [ + _resolvedImage(context, slide.imagePath, projectPath), + _captionOverlay(context, slide.imageCaption, w), + ], + ), + ), + Positioned( + top: 0, + left: 0, + right: imgWidth + gap, + bottom: 0, + child: Padding( + padding: EdgeInsets.fromLTRB( + leftPad, + verticalPad + safe.top, + 0, + verticalPad + safe.bottom, + ), + // FittedBox stays as a safety net for measurement rounding; with + // an accurate scale it renders at scale 1 (full width). + child: FittedBox( + fit: BoxFit.scaleDown, + alignment: Alignment.topLeft, + child: SizedBox( + width: availW, + child: _contentColumn( + context: context, + scale: scale, + bullets: bullets, + hasTitle: hasTitle, + titleSize: titleSize, + bulletSize: bulletSize, + spacing: spacing, + bulletGap: bulletGap, + ), + ), + ), + ), + ), + ], + ), + ); + } + + Widget _contentColumn({ + required BuildContext context, + required double scale, + required List bullets, + required bool hasTitle, + required double titleSize, + required double bulletSize, + required double spacing, + required double bulletGap, + }) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + if (hasTitle) + _md( + context, + slide.title, + _applyFont( + font, + TextStyle( + fontSize: titleSize * scale, + fontWeight: FontWeight.bold, + color: _hexColor(profile.textColor), + ), + ), + linkColor: _hexColor(profile.accentColor), + ), + if (hasTitle && bullets.isNotEmpty) SizedBox(height: spacing * scale), + if (slide.listStyle == ListStyle.checklist && + slide.showChecklistProgress && + bullets.isNotEmpty) ...[ + _ChecklistProgress( + bullets: bullets, + w: w, + font: font, + profile: profile, + ), + SizedBox(height: spacing * scale), + ], + ...bullets.asMap().entries.map((entry) { + final b = entry.value; + int level = 0; + while (level < b.length && b[level] == '\t') { + level++; + } + final text = slide.listStyle == ListStyle.checklist + ? checklistItemText(b) + : b.substring(level); + final checked = + slide.listStyle == ListStyle.checklist && checklistItemChecked(b); + final fontSize = bulletSize * _bulletLevelScale(level) * scale; + return _ChecklistBulletRow( + bullets: bullets, + itemIndex: entry.key, + column: 0, + listStyle: slide.listStyle, + checked: checked, + text: text, + level: level, + fontSize: fontSize, + bulletSize: bulletSize, + bulletGap: bulletGap, + scale: scale, + font: font, + profile: profile, + ); + }), + ], + ); + } +} + +class _BulletListColumn extends StatelessWidget { + final List bullets; + final ListStyle listStyle; + final String font; + final ThemeProfile profile; + final double bulletSize; + final double bulletGap; + final double scale; + final int column; + + const _BulletListColumn({ + required this.bullets, + required this.listStyle, + required this.font, + required this.profile, + required this.bulletSize, + required this.bulletGap, + required this.scale, + this.column = 0, + }); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + ...bullets.asMap().entries.map((entry) { + final b = entry.value; + int level = 0; + while (level < b.length && b[level] == '\t') { + level++; + } + final text = listStyle == ListStyle.checklist + ? checklistItemText(b) + : b.substring(level); + final checked = + listStyle == ListStyle.checklist && checklistItemChecked(b); + final fontSize = bulletSize * _bulletLevelScale(level) * scale; + return _ChecklistBulletRow( + bullets: bullets, + itemIndex: entry.key, + column: column, + listStyle: listStyle, + checked: checked, + text: text, + level: level, + fontSize: fontSize, + bulletSize: bulletSize, + bulletGap: bulletGap, + scale: scale, + font: font, + profile: profile, + ); + }), + ], + ); + } +} + +/// Upper bound for growing bullet text to fill otherwise empty vertical space. +const double _kBulletsMaxScale = 3.2; + +/// Split slides have a much narrower column, so short bullet lists can stay +/// visually timid unless they are allowed to grow a little further. +const double _kSplitBulletsMaxScale = 4.35; + +/// Hard ceiling for the *rendered* bullet text size when auto-growing, as a +/// fraction of the slide width: ≈32pt on a standard 16:9 deck (PowerPoint's +/// 960pt-wide canvas). Presentation-design guidance consistently puts body +/// text at 24–32pt — beyond that it stops aiding readability and starts +/// competing with the title. The fit scale multiplies title and bullets +/// alike, so capping the bullet size also keeps the hierarchy intact. +const double _kBulletMaxFontFraction = 0.0335; + +/// The largest auto-fit scale that keeps bullets at or under +/// [_kBulletMaxFontFraction], given the layout's own [layoutMax] growth bound. +double _bulletScaleCap(double w, double bulletSize, double layoutMax) => + math.min(layoutMax, _kBulletMaxFontFraction * w / bulletSize); + +/// Line height used for bullet body text, shared by rendering and measuring. +const double _kBulletLineHeight = 1.16; + +String _bulletMarkerForLevel(int level) { + const markers = ['•', '◦', '▪', '▫', '–']; + return markers[level.clamp(0, markers.length - 1)]; +} + +String _listMarker(List items, int index, ListStyle style) { + int levelOf(String item) { + var level = 0; + while (level < item.length && item[level] == '\t') { + level++; + } + return level; + } + + final level = levelOf(items[index]); + if (style == ListStyle.bullets) return _bulletMarkerForLevel(level); + if (style == ListStyle.checklist) { + return checklistItemChecked(items[index]) ? '☑' : '☐'; + } + var number = 0; + for (var i = 0; i <= index; i++) { + final itemLevel = levelOf(items[i]); + if (itemLevel == level) number++; + if (itemLevel < level) number = 0; + } + return '$number.'; +} + +double _bulletLevelScale(int level) { + if (level <= 0) return 1.0; + if (level == 1) return 0.86; + if (level == 2) return 0.80; + return 0.76; +} + +/// Largest scale in [minScale, maxScale] for which the bullet block fits +/// [availH] at the full column width. Unlike a plain `BoxFit.scaleDown`, this +/// also grows the text *above* its design size when there is spare vertical +/// room, so short slides use the full height instead of clustering at the top. +double _bulletsFitScale({ + required double availW, + required double availH, + required bool hasTitle, + required String title, + required List bullets, + required double titleSize, + required double bulletSize, + required double spacing, + required double bulletGap, + required String font, + String subtitle = '', + double subtitleSize = 0, + double minScale = 0.2, + double maxScale = 1.0, + ListStyle listStyle = ListStyle.bullets, +}) { + if (availW <= 0 || !availH.isFinite || availH <= 0) return 1.0; + // 2% safety margin so minor measurement differences never overflow. + final budget = availH * 0.98; + double measure(double scale) => _bulletsBlockHeight( + scale: scale, + availW: availW, + listStyle: listStyle, + hasTitle: hasTitle, + title: title, + bullets: bullets, + titleSize: titleSize, + bulletSize: bulletSize, + spacing: spacing, + bulletGap: bulletGap, + font: font, + subtitle: subtitle, + subtitleSize: subtitleSize, + ); + + // Everything already fits at the largest allowed size → use it. + if (measure(maxScale) <= budget) return maxScale; + + // Otherwise binary-search the largest scale that fits. Search upward from the + // design size when it fits, downward when even the design size overflows. + double lo, hi; + if (maxScale > 1.0 && measure(1.0) <= budget) { + lo = 1.0; + hi = maxScale; + } else { + lo = minScale; + hi = maxScale > 1.0 ? 1.0 : maxScale; + } + for (var i = 0; i < 24; i++) { + final mid = (lo + hi) / 2; + if (measure(mid) <= budget) { + lo = mid; + } else { + hi = mid; + } + } + return lo; +} + +double _bulletsBlockHeight({ + required double scale, + required double availW, + required bool hasTitle, + required String title, + required List bullets, + required double titleSize, + required double bulletSize, + required double spacing, + required double bulletGap, + required String font, + String subtitle = '', + double subtitleSize = 0, + ListStyle listStyle = ListStyle.bullets, +}) { + var height = 0.0; + if (hasTitle) { + height += _measureTextHeight( + title, + titleSize * scale, + availW, + bold: true, + fontFamily: font, + ); + } + if (subtitle.isNotEmpty) { + height += spacing * scale * 0.4; + height += _measureTextHeight( + subtitle, + subtitleSize * scale, + availW, + bold: true, + fontFamily: font, + ); + } + if ((hasTitle || subtitle.isNotEmpty) && bullets.isNotEmpty) { + height += spacing * scale; + } + for (var i = 0; i < bullets.length; i++) { + final b = bullets[i]; + int level = 0; + while (level < b.length && b[level] == '\t') { + level++; + } + // Measure exactly what gets rendered: checklists strip the `[x] ` prefix + // and use a checkbox marker, numbered lists use `N.`. Measuring the raw + // string with a bullet marker over-counts the height and would shrink the + // text below the space it actually needs. + final text = listStyle == ListStyle.checklist + ? checklistItemText(b) + : b.substring(level); + final fontSize = bulletSize * _bulletLevelScale(level) * scale; + final indent = level * bulletSize * 1.05 * scale; + final marker = '${_listMarker(bullets, i, listStyle)} '; + final markerW = _measureTextWidth( + marker, + fontSize, + bold: true, + fontFamily: font, + ); + final wrapW = (availW - indent - markerW).clamp(1.0, availW); + final textH = _measureTextHeight( + text, + fontSize, + wrapW, + lineHeight: _kBulletLineHeight, + fontFamily: font, + ); + final markerH = _measureTextHeight( + marker, + fontSize, + double.infinity, + fontFamily: font, + ); + height += bulletGap * scale * 2 + (textH > markerH ? textH : markerH); + } + return height; +} + +double _measureTextHeight( + String text, + double fontSize, + double maxWidth, { + double? lineHeight, + bool bold = false, + String? fontFamily, +}) { + final painter = TextPainter( + text: TextSpan( + text: stripInlineMarkdown(text), + style: TextStyle( + fontFamily: fontFamily, + fontSize: fontSize, + height: lineHeight, + fontWeight: bold ? FontWeight.bold : null, + ), + ), + textDirection: TextDirection.ltr, + )..layout(maxWidth: maxWidth.isFinite ? maxWidth : double.infinity); + return painter.height; +} + +double _measureTextWidth( + String text, + double fontSize, { + bool bold = false, + String? fontFamily, +}) { + final painter = TextPainter( + text: TextSpan( + text: stripInlineMarkdown(text), + style: TextStyle( + fontFamily: fontFamily, + fontSize: fontSize, + fontWeight: bold ? FontWeight.bold : null, + ), + ), + textDirection: TextDirection.ltr, + )..layout(); + return painter.width; +} diff --git a/lib/widgets/slides/previews/chart_preview.dart b/lib/widgets/slides/previews/chart_preview.dart new file mode 100644 index 0000000..3f2b8b4 --- /dev/null +++ b/lib/widgets/slides/previews/chart_preview.dart @@ -0,0 +1,1478 @@ +// Part of the slide_preview library — see ../slide_preview.dart. +// Split out for navigability; all imports live in the main library file. +part of '../slide_preview.dart'; + +/// Renders a chart slide (bar/line/pie) from its ```chart JSON spec. +class _ChartPreview extends StatefulWidget { + final Slide slide; + final double w; + final String font; + final ThemeProfile profile; + final bool presentationMode; + + const _ChartPreview({ + required this.slide, + required this.w, + required this.font, + required this.profile, + required this.presentationMode, + }); + + @override + State<_ChartPreview> createState() => _ChartPreviewState(); +} + +class _ChartPreviewState extends State<_ChartPreview> { + Slide get slide => widget.slide; + double get w => widget.w; + String get font => widget.font; + ThemeProfile get profile => widget.profile; + bool get presentationMode => widget.presentationMode; + + /// Legend entry the pointer is over: a series index for bar/line charts, or a + /// slice (category) index for pie charts. Null when nothing is hovered. + int? _hovered; + + /// The radar vertex under the pointer, used to draw its tooltip. Null when not + /// hovering a point. + ({int series, int entry, double value, Offset offset})? _radarTouch; + + void _setHover(int? index) { + if (_hovered != index) setState(() => _hovered = index); + } + + /// True when another legend entry is hovered, so [index] should fade back. + bool _dimmed(int index) => _hovered != null && _hovered != index; + + /// Series colour with legend-hover feedback: non-hovered series fade out so + /// the hovered one stands out in the plot. + Color _seriesDisplayColor(ChartSeries series, int i) { + final base = _seriesColor(series, i); + return _dimmed(i) ? base.withValues(alpha: 0.2) : base; + } + + double get _labelScale => presentationMode ? 1.12 : 1; + + Color _seriesColor(ChartSeries series, int i) { + if (series.color == null && i == 0) { + return _hexColor(profile.accentColor); + } + return _hexColor(chartSeriesColor(series, i)); + } + + /// Text alternative for the chart (WCAG 1.1.1): chart type, title and the + /// underlying values per series, so a screen reader conveys the same + /// information the visual encodes. + String _semanticsLabel(BuildContext context, ChartSpec spec) { + final l10n = context.l10n; + final typeName = switch (spec.type) { + ChartType.bar => l10n.d('Staaf'), + ChartType.line => l10n.d('Lijn'), + ChartType.pie => l10n.d('Cirkel'), + ChartType.radar => l10n.d('Spider'), + }; + final buffer = StringBuffer('${l10n.d('Grafiek')} ($typeName)'); + if (spec.title.isNotEmpty) { + buffer.write(': ${stripInlineMarkdown(spec.title)}'); + } + if (!spec.hasInlineData) return buffer.toString(); + for (var si = 0; si < spec.series.length; si++) { + final series = spec.series[si]; + final name = series.name.isEmpty + ? '${l10n.d('Reeks')} ${si + 1}' + : series.name; + final values = [ + for (var xi = 0; xi < spec.x.length && xi < series.data.length; xi++) + '${spec.x[xi]} ${_fmtNum(series.data[xi])}', + ]; + buffer.write('. $name: ${values.join(', ')}'); + } + return buffer.toString(); + } + + @override + Widget build(BuildContext context) { + final spec = ChartSpec.parse(slide.customMarkdown); + final horizontalPad = w * 0.05; + final verticalPad = w * 0.018; + final safe = slide.showLogo ? _logoSafeInsets(w, profile) : EdgeInsets.zero; + final textColor = _hexColor(profile.textColor); + + return Semantics( + image: true, + label: _semanticsLabel(context, spec), + // The visual chart (axis labels, legend chips, tooltips) would read as + // disconnected fragments; the label above carries the full story. + child: ExcludeSemantics( + child: _chartBody( + context, + spec, + horizontalPad, + verticalPad, + safe, + textColor, + ), + ), + ); + } + + Widget _chartBody( + BuildContext context, + ChartSpec spec, + double horizontalPad, + double verticalPad, + EdgeInsets safe, + Color textColor, + ) { + return Container( + color: _hexColor(profile.slideBackgroundColor), + child: Padding( + padding: EdgeInsets.fromLTRB( + horizontalPad, + verticalPad + safe.top, + horizontalPad, + verticalPad + safe.bottom, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (spec.title.isNotEmpty) ...[ + Container( + width: double.infinity, + padding: EdgeInsets.symmetric( + horizontal: w * 0.025, + vertical: w * 0.01, + ), + decoration: BoxDecoration( + color: _hexColor(profile.titleBackgroundColor), + borderRadius: BorderRadius.circular(w * 0.012), + border: Border( + left: BorderSide( + color: _hexColor(profile.accentColor), + width: w * 0.006, + ), + ), + ), + child: _md( + context, + spec.title, + _applyFont( + font, + TextStyle( + fontSize: w * 0.032, + height: 1.1, + fontWeight: FontWeight.bold, + color: _hexColor(profile.titleTextColor), + ), + ), + linkColor: _hexColor(profile.accentColor), + ), + ), + SizedBox(height: w * 0.012), + ], + Expanded( + child: Container( + key: const ValueKey('chart-surface'), + padding: EdgeInsets.fromLTRB( + w * 0.02, + w * 0.01, + w * 0.025, + w * 0.01, + ), + decoration: BoxDecoration( + color: textColor.withValues(alpha: 0.035), + borderRadius: BorderRadius.circular(w * 0.014), + border: Border.all(color: textColor.withValues(alpha: 0.09)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Expanded( + child: spec.hasInlineData + ? _chart(spec, textColor) + : _placeholder(context), + ), + if (spec.hasInlineData && spec.series.isNotEmpty) ...[ + SizedBox(height: w * 0.006), + spec.type == ChartType.pie + ? _pieLegend(spec, textColor) + : _legend(spec, textColor), + ], + ], + ), + ), + ), + ], + ), + ), + ); + } + + Widget _legend(ChartSpec spec, Color textColor) { + return SizedBox( + height: w * 0.03, + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: [ + for (var i = 0; i < spec.series.length; i++) ...[ + if (i > 0) SizedBox(width: w * 0.01), + MouseRegion( + onEnter: (_) => _setHover(i), + onExit: (_) => _setHover(null), + child: AnimatedOpacity( + duration: const Duration(milliseconds: 120), + opacity: _dimmed(i) ? 0.4 : 1, + child: Container( + padding: EdgeInsets.symmetric( + horizontal: w * 0.01, + vertical: w * 0.004, + ), + decoration: BoxDecoration( + color: _hovered == i + ? _seriesColor( + spec.series[i], + i, + ).withValues(alpha: 0.18) + : textColor.withValues(alpha: 0.045), + borderRadius: BorderRadius.circular(w), + border: Border.all( + color: _hovered == i + ? _seriesColor(spec.series[i], i) + : Colors.transparent, + width: w * 0.0015, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: w * 0.012, + height: w * 0.012, + decoration: BoxDecoration( + color: _seriesColor(spec.series[i], i), + shape: BoxShape.circle, + ), + ), + SizedBox(width: w * 0.006), + ConstrainedBox( + constraints: BoxConstraints(maxWidth: w * 0.16), + child: Text( + spec.series[i].name.isEmpty + ? 'Reeks ${i + 1}' + : spec.series[i].name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: _applyFont( + font, + TextStyle( + fontSize: w * 0.013, + fontWeight: FontWeight.w600, + color: textColor.withValues(alpha: 0.82), + ), + ), + ), + ), + ], + ), + ), + ), + ), + ], + ], + ), + ), + ); + } + + Widget _pieLegend(ChartSpec spec, Color textColor) { + final itemCount = math.min(spec.x.length, 18); + final columns = math.min(itemCount, presentationMode ? 4 : 6); + final rows = (itemCount / columns).ceil(); + return LayoutBuilder( + builder: (context, constraints) { + final gap = w * 0.006; + final itemWidth = + (constraints.maxWidth - gap * (columns - 1)) / columns; + return SizedBox( + height: rows * w * 0.03 * _labelScale + (rows - 1) * gap, + child: Wrap( + spacing: gap, + runSpacing: gap, + children: [ + for (var i = 0; i < itemCount; i++) + MouseRegion( + onEnter: (_) => _setHover(i), + onExit: (_) => _setHover(null), + child: AnimatedOpacity( + duration: const Duration(milliseconds: 120), + opacity: _dimmed(i) ? 0.4 : 1, + child: Container( + width: itemWidth, + height: w * 0.03 * _labelScale, + padding: EdgeInsets.symmetric(horizontal: w * 0.008), + decoration: BoxDecoration( + color: _hovered == i + ? _hexColor( + chartRowColor(spec, i), + ).withValues(alpha: 0.18) + : textColor.withValues(alpha: 0.045), + borderRadius: BorderRadius.circular(w), + border: Border.all( + color: _hovered == i + ? _hexColor(chartRowColor(spec, i)) + : Colors.transparent, + width: w * 0.0015, + ), + ), + child: Row( + children: [ + Container( + width: w * 0.012, + height: w * 0.012, + decoration: BoxDecoration( + color: _hexColor(chartRowColor(spec, i)), + shape: BoxShape.circle, + ), + ), + SizedBox(width: w * 0.006), + Expanded( + child: Text( + spec.x[i], + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: _applyFont( + font, + TextStyle( + fontSize: w * 0.013 * _labelScale, + fontWeight: FontWeight.w600, + color: textColor.withValues(alpha: 0.82), + ), + ), + ), + ), + ], + ), + ), + ), + ), + ], + ), + ); + }, + ); + } + + Widget _chart(ChartSpec spec, Color textColor) { + switch (spec.type) { + case ChartType.bar: + return _barChart(spec, textColor); + case ChartType.line: + return _lineChart(spec, textColor); + case ChartType.pie: + return _pieChart(spec, textColor); + case ChartType.radar: + return _radarChart(spec, textColor); + } + } + + double _maxY(ChartSpec spec) { + var m = 0.0; + for (final s in spec.series) { + for (final v in s.data) { + if (v > m) m = v; + } + } + // Keep any bound line comfortably inside the plot so its label is visible. + if (spec.supportsBounds) { + for (final b in [spec.minBound, spec.maxBound]) { + if (b != null && b > m) m = b; + } + } + return m <= 0 ? 1 : m * 1.15; + } + + double _minY(ChartSpec spec) { + var m = 0.0; + for (final s in spec.series) { + for (final v in s.data) { + if (v < m) m = v; + } + } + if (spec.supportsBounds) { + for (final b in [spec.minBound, spec.maxBound]) { + if (b != null && b < m) m = b; + } + } + return m >= 0 ? 0 : m * 1.15; + } + + /// Optional min/max threshold lines drawn across the plot (bar/line only). + ExtraLinesData _boundLines(ChartSpec spec) { + if (!spec.supportsBoundLines) return const ExtraLinesData(); + final dash = [ + (w * 0.018).round().clamp(4, 14), + (w * 0.01).round().clamp(3, 9), + ]; + HorizontalLine line(double value, Color color, String prefix) => + HorizontalLine( + y: value, + color: color, + strokeWidth: w * 0.0035, + dashArray: dash, + label: HorizontalLineLabel( + show: true, + alignment: Alignment.topRight, + padding: EdgeInsets.only(right: w * 0.006, bottom: w * 0.002), + style: _applyFont( + font, + TextStyle( + fontSize: w * 0.0115 * _labelScale, + color: color, + fontWeight: FontWeight.w700, + ), + ), + labelResolver: (_) => '$prefix ${_fmtNum(value)}', + ), + ); + return ExtraLinesData( + horizontalLines: [ + if (spec.minBound != null) + line(spec.minBound!, const Color(0xFFF59E0B), 'min'), + if (spec.maxBound != null) + line(spec.maxBound!, const Color(0xFFEF4444), 'max'), + ], + ); + } + + FlTitlesData _titles(ChartSpec spec, Color textColor, {bool bars = false}) { + final style = _applyFont( + font, + TextStyle( + fontSize: w * 0.0115 * _labelScale, + color: textColor.withValues(alpha: 0.88), + fontWeight: presentationMode ? FontWeight.w600 : FontWeight.normal, + ), + ); + return FlTitlesData( + topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)), + rightTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)), + leftTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + reservedSize: w * 0.05 * _labelScale, + getTitlesWidget: (value, meta) => Text( + _fmtNum(value), + style: style.copyWith(fontSize: w * 0.0105 * _labelScale), + ), + ), + ), + bottomTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + interval: 1, + reservedSize: w * 0.044 * _labelScale, + getTitlesWidget: (value, meta) { + final i = value.round(); + final n = spec.x.length; + if (i < 0 || i >= n) return const SizedBox.shrink(); + // Show as many labels as fit without colliding: keep at least + // [minSlot] of horizontal room per label, then thin them out + // evenly based on the actual pixel spacing between points. Line + // charts spread n points over n-1 intervals; bar groups are laid + // out spaceEvenly, which puts their centres (axis + groupWidth) / + // (n + 1) apart. + final spacing = bars + ? (meta.parentAxisSize + _barGroupWidth(spec)) / (n + 1) + : (n > 1 ? meta.parentAxisSize / (n - 1) : meta.parentAxisSize); + final minSlot = w * 0.085 * _labelScale; + final step = math.max(1, (minSlot / spacing).ceil()); + final lastMultiple = ((n - 1) ~/ step) * step; + final lastGap = n - 1 - lastMultiple; + final showLast = i == n - 1 && lastGap > step / 2; + if (i % step != 0 && !showLast) return const SizedBox.shrink(); + // The extra end label can sit closer than a full step to its + // neighbour; shrink both of their slots to the real gap so they + // never run through each other. + var slotSteps = step.toDouble(); + if (showLast || (i == lastMultiple && lastGap > step / 2)) { + slotSteps = math.min(slotSteps, lastGap.toDouble()); + } + final slot = (slotSteps * spacing - w * 0.012).clamp( + w * 0.04, + w * 0.16, + ); + return Padding( + padding: EdgeInsets.only(top: w * 0.008), + child: SizedBox( + width: slot, + child: Text( + spec.x[i], + style: style, + maxLines: 2, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + ), + ), + ); + }, + ), + ), + ); + } + + String _fmtNum(double v) { + if (v == v.roundToDouble()) return v.toInt().toString(); + return v.toStringAsFixed(1); + } + + FlGridData _grid(Color textColor) => FlGridData( + show: true, + drawVerticalLine: false, + getDrawingHorizontalLine: (v) => + FlLine(color: textColor.withValues(alpha: 0.12), strokeWidth: 1), + ); + + /// Width of one bar rod, shared by the chart and the axis-label spacing. + double _barRodWidth(ChartSpec spec) => + (w * 0.032 / spec.series.length).clamp(w * 0.008, w * 0.022); + + /// Total width of one bar group: its rods plus fl_chart's default 2px + /// spacing between rods within a group. + double _barGroupWidth(ChartSpec spec) { + final rods = math.max(1, spec.series.length); + return rods * _barRodWidth(spec) + (rods - 1) * 2; + } + + Widget _barChart(ChartSpec spec, Color textColor) { + final groups = []; + for (var xi = 0; xi < spec.x.length; xi++) { + groups.add( + BarChartGroupData( + x: xi, + barRods: [ + for (var si = 0; si < spec.series.length; si++) + if (xi < spec.series[si].data.length) + BarChartRodData( + toY: spec.series[si].data[xi], + color: _seriesDisplayColor(spec.series[si], si), + width: _barRodWidth(spec), + borderRadius: BorderRadius.vertical( + top: Radius.circular(w * 0.006), + ), + backDrawRodData: BackgroundBarChartRodData( + show: true, + toY: _maxY(spec), + color: textColor.withValues(alpha: 0.025), + ), + ), + ], + ), + ); + } + return BarChart( + BarChartData( + minY: _minY(spec), + maxY: _maxY(spec), + // The axis-label spacing in _titles assumes this layout; keep it + // explicit rather than relying on fl_chart's default. + alignment: BarChartAlignment.spaceEvenly, + barGroups: groups, + titlesData: _titles(spec, textColor, bars: true), + gridData: _grid(textColor), + borderData: FlBorderData(show: false), + extraLinesData: _boundLines(spec), + barTouchData: BarTouchData( + enabled: true, + mouseCursorResolver: (event, response) => response?.spot == null + ? SystemMouseCursors.basic + : SystemMouseCursors.click, + touchTooltipData: BarTouchTooltipData( + fitInsideHorizontally: true, + fitInsideVertically: true, + getTooltipColor: (_) => const Color(0xFF0F172A), + getTooltipItem: (group, groupIndex, rod, rodIndex) { + final label = group.x >= 0 && group.x < spec.x.length + ? spec.x[group.x] + : ''; + final series = rodIndex < spec.series.length + ? spec.series[rodIndex].name + : ''; + return BarTooltipItem( + '$label\n${series.isEmpty ? 'Reeks ${rodIndex + 1}' : series}: ${_fmtNum(rod.toY)}', + _tooltipStyle(), + ); + }, + ), + ), + ), + duration: Duration.zero, + ); + } + + Widget _lineChart(ChartSpec spec, Color textColor) { + final bars = []; + for (var si = 0; si < spec.series.length; si++) { + bars.add( + LineChartBarData( + spots: [ + for (var xi = 0; xi < spec.series[si].data.length; xi++) + FlSpot(xi.toDouble(), spec.series[si].data[xi]), + ], + color: _seriesDisplayColor(spec.series[si], si), + barWidth: w * (_hovered == si ? 0.0065 : 0.0045), + isCurved: true, + curveSmoothness: 0.22, + dotData: FlDotData( + show: true, + getDotPainter: (spot, percent, bar, index) => FlDotCirclePainter( + radius: w * 0.005, + color: _seriesDisplayColor(spec.series[si], si), + strokeWidth: w * 0.0025, + strokeColor: _hexColor(profile.slideBackgroundColor), + ), + ), + belowBarData: BarAreaData( + show: true, + color: _seriesDisplayColor( + spec.series[si], + si, + ).withValues(alpha: spec.series.length == 1 ? 0.14 : 0.05), + ), + ), + ); + } + return LineChart( + LineChartData( + minY: _minY(spec), + maxY: _maxY(spec), + lineBarsData: bars, + titlesData: _titles(spec, textColor), + gridData: _grid(textColor), + borderData: FlBorderData(show: false), + extraLinesData: _boundLines(spec), + lineTouchData: LineTouchData( + enabled: true, + // Measure proximity to the actual dot (x *and* y), not just the + // column, so the tooltip belongs to the point under the cursor. + distanceCalculator: (touch, spot) => (touch - spot).distance, + touchSpotThreshold: (w * 0.02).clamp(8.0, 24.0).toDouble(), + mouseCursorResolver: (event, response) => + response?.lineBarSpots?.isEmpty ?? true + ? SystemMouseCursors.basic + : SystemMouseCursors.click, + touchTooltipData: LineTouchTooltipData( + fitInsideHorizontally: true, + fitInsideVertically: true, + getTooltipColor: (_) => const Color(0xFF0F172A), + // Show every dot near the cursor. When several dots sit on (almost) + // the same spot they all appear; the font shrinks to keep them + // readable when stacked. + getTooltipItems: (spots) { + final style = _lineTooltipStyle(spots.length); + return [ + for (final spot in spots) + LineTooltipItem( + '${spot.spotIndex < spec.x.length ? spec.x[spot.spotIndex] : ''}\n' + '${spot.barIndex < spec.series.length && spec.series[spot.barIndex].name.isNotEmpty ? spec.series[spot.barIndex].name : 'Reeks ${spot.barIndex + 1}'}: ${_fmtNum(spot.y)}', + style, + ), + ]; + }, + ), + ), + ), + duration: Duration.zero, + ); + } + + Widget _pieChart(ChartSpec spec, Color textColor) { + if (spec.series.isEmpty || spec.x.isEmpty) { + return _placeholderText('—'); + } + return LayoutBuilder( + builder: (context, constraints) { + final visibleSeries = math.min(spec.series.length, 2); + final columns = visibleSeries; + const rows = 1; + final tileHeight = constraints.maxHeight / rows; + final tileWidth = constraints.maxWidth / columns; + return GridView.builder( + padding: EdgeInsets.zero, + physics: const NeverScrollableScrollPhysics(), + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: columns, + childAspectRatio: tileWidth / tileHeight, + crossAxisSpacing: w * 0.012, + mainAxisSpacing: w * 0.008, + ), + itemCount: visibleSeries, + itemBuilder: (context, si) { + final series = spec.series[si]; + final values = [ + for (var xi = 0; xi < spec.x.length; xi++) + xi < series.data.length && series.data[xi] > 0 + ? series.data[xi] + : 0.0, + ]; + final total = values.fold(0, (a, b) => a + b); + return Row( + children: [ + Expanded( + flex: 4, + child: total <= 0 + ? Center( + child: Text( + '0', + style: _applyFont( + font, + TextStyle( + fontSize: w * 0.025, + color: textColor.withValues(alpha: 0.5), + ), + ), + ), + ) + : LayoutBuilder( + builder: (context, pieConstraints) { + final available = + pieConstraints.biggest.shortestSide; + final radius = (available * 0.42).clamp( + w * 0.018, + w * 0.075, + ); + return ClipRect( + child: _HoverPieChart( + externalHover: _hovered, + values: values, + labels: spec.x, + colors: [ + for (var xi = 0; xi < values.length; xi++) + _hexColor(chartRowColor(spec, xi)), + ], + radius: radius, + centerSpaceRadius: radius * 0.42, + sectionSpace: w * 0.002, + titleStyle: _applyFont( + font, + TextStyle( + fontSize: (radius * 0.18).clamp( + w * 0.009, + w * 0.013, + ), + color: Colors.white, + fontWeight: FontWeight.bold, + ), + ), + tooltipStyle: _tooltipStyle(), + ), + ); + }, + ), + ), + SizedBox(width: w * 0.008), + Expanded( + flex: 2, + child: Text( + series.name.isEmpty ? 'Reeks ${si + 1}' : series.name, + maxLines: 3, + overflow: TextOverflow.ellipsis, + style: _applyFont( + font, + TextStyle( + fontSize: w * 0.015, + height: 1.1, + fontWeight: FontWeight.w700, + color: textColor, + ), + ), + ), + ), + ], + ); + }, + ); + }, + ); + } + + Widget _radarChart(ChartSpec spec, Color textColor) { + if (spec.x.length < 3 || spec.series.isEmpty) { + return _placeholderText( + context.l10n.d('Een spider-diagram heeft minstens drie labels nodig'), + ); + } + final grid = textColor.withValues(alpha: 0.18); + final scale = radarScale(spec); + + return Padding( + padding: EdgeInsets.symmetric(horizontal: w * 0.02, vertical: w * 0.012), + child: LayoutBuilder( + builder: (context, constraints) { + // Reserve a slim column on the right for the scale legend; the rest + // of the area is shared between the spider and its axis labels. + final legendWidth = w * 0.075; + final boxW = math.max( + 0.0, + constraints.maxWidth - legendWidth - w * 0.02, + ); + final boxH = constraints.maxHeight; + if (boxW <= 0 || !boxH.isFinite || boxH <= 0) { + return const SizedBox.shrink(); + } + // Measure every axis label and grow the spider until the labels just + // fit between the polygon and the edges of the available area, so + // the diagram uses the space the old fixed label bands wasted. + final layout = _radarLabelLayout(spec, boxW, boxH, textColor); + final chartSide = layout.chartSide; + + return Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Expanded( + child: Center( + child: SizedBox( + width: boxW, + height: boxH, + child: Stack( + children: [ + for (var i = 0; i < spec.x.length; i++) + _radarAxisLabel( + label: spec.x[i], + index: i, + count: spec.x.length, + layout: layout, + textColor: textColor, + ), + Positioned( + left: (boxW - chartSide) / 2, + top: (boxH - chartSide) / 2, + width: chartSide, + height: chartSide, + child: Stack( + clipBehavior: Clip.none, + children: [ + Positioned.fill( + child: RadarChart( + RadarChartData( + dataSets: [ + for ( + var si = 0; + si < spec.series.length; + si++ + ) + RadarDataSet( + dataEntries: [ + for ( + var xi = 0; + xi < spec.x.length; + xi++ + ) + RadarEntry( + value: + xi < + spec + .series[si] + .data + .length + ? spec.series[si].data[xi] + : 0, + ), + ], + fillColor: + _seriesDisplayColor( + spec.series[si], + si, + ).withValues( + alpha: _dimmed(si) + ? 0.04 + : 0.16, + ), + borderColor: _seriesDisplayColor( + spec.series[si], + si, + ), + borderWidth: + w * + (_hovered == si + ? 0.0055 + : 0.0035), + entryRadius: + w * + (_hovered == si ? 0.006 : 0.004), + ), + // Invisible anchor pinning the scale to [lo, hi] + // so the rings represent a fixed scale. + RadarDataSet( + dataEntries: [ + for ( + var xi = 0; + xi < spec.x.length; + xi++ + ) + RadarEntry( + value: xi == 0 + ? scale.hi + : scale.lo, + ), + ], + fillColor: Colors.transparent, + borderColor: Colors.transparent, + borderWidth: 0, + entryRadius: 0, + ), + ], + radarShape: RadarShape.polygon, + radarBackgroundColor: Colors.transparent, + radarBorderData: BorderSide( + color: grid, + width: 1, + ), + gridBorderData: BorderSide( + color: grid, + width: 1, + ), + tickBorderData: BorderSide( + color: grid, + width: 1, + ), + tickCount: scale.ticks, + isMinValueAtCenter: true, + // The scale now lives in a side legend, so hide + // fl_chart's in-chart ring numbers. + ticksTextStyle: const TextStyle( + color: Colors.transparent, + fontSize: 0.001, + ), + titlePositionPercentageOffset: 0, + getTitle: (index, angle) => RadarChartTitle( + text: index < spec.x.length + ? spec.x[index] + : '', + ), + // Labels are rendered as constrained widgets + // around the chart so long text can wrap. + titleTextStyle: const TextStyle( + color: Colors.transparent, + fontSize: 0.001, + ), + radarTouchData: RadarTouchData( + enabled: true, + touchSpotThreshold: (w * 0.02) + .clamp(8.0, 24.0) + .toDouble(), + mouseCursorResolver: (event, response) => + _radarSpotFrom(response, spec) == null + ? SystemMouseCursors.basic + : SystemMouseCursors.click, + touchCallback: (event, response) { + final next = + event.isInterestedForInteractions + ? _radarSpotFrom(response, spec) + : null; + if (next != _radarTouch) { + setState(() => _radarTouch = next); + } + }, + ), + ), + duration: Duration.zero, + ), + ), + if (_radarTouch != null) + _radarTooltip(spec, chartSide, _radarTouch!), + ], + ), + ), + ], + ), + ), + ), + ), + SizedBox( + width: legendWidth, + child: _radarScaleLegend(scale, textColor), + ), + ], + ); + }, + ), + ); + } + + TextStyle _radarLabelStyle(int count, Color textColor) => _applyFont( + font, + TextStyle( + fontSize: w * (count <= 6 ? 0.013 : 0.0115) * _labelScale, + height: 1.05, + color: textColor.withValues(alpha: 0.88), + fontWeight: presentationMode ? FontWeight.w600 : FontWeight.w500, + ), + ); + + /// True when the vertex in [direction] gets its label placed beside the + /// polygon (left/right) rather than above/below it. + static bool _radarLabelBeside(Offset direction) => direction.dx.abs() > 0.35; + + /// Sizes the spider and places every axis label around it. + /// + /// Each label is measured at its real text size, then the polygon radius is + /// grown until the tightest label exactly fits between the polygon and the + /// edge of the [boxW]×[boxH] area. fl_chart draws the polygon at 0.4× the + /// side of its (square) widget, which is what ties [chartSide] to the + /// resulting radius. + ({double chartSide, List rects, List aligns, int maxLines}) + _radarLabelLayout(ChartSpec spec, double boxW, double boxH, Color textColor) { + const radiusFactor = 0.4; // fl_chart: radius = min(w, h) / 2 * 0.8 + final n = spec.x.length; + final style = _radarLabelStyle(n, textColor); + final gap = w * 0.008; + final maxLines = n <= 6 ? 3 : 2; + final sideCap = math.min(boxW * 0.28, w * 0.2); + final topCap = math.min(boxW * 0.5, w * 0.3); + + Size measure(String text, double maxWidth) { + final painter = TextPainter( + text: TextSpan(text: text, style: style), + textDirection: TextDirection.ltr, + maxLines: maxLines, + ellipsis: '…', + )..layout(maxWidth: math.max(0.0, maxWidth)); + final size = Size(painter.width, painter.height); + painter.dispose(); + return size; + } + + final directions = []; + final sizes = []; + for (var i = 0; i < n; i++) { + final angle = (2 * math.pi * i / n) - math.pi / 2; + final dir = Offset(math.cos(angle), math.sin(angle)); + directions.add(dir); + sizes.add(measure(spec.x[i], _radarLabelBeside(dir) ? sideCap : topCap)); + } + + // The largest polygon radius every label still fits next to. + var radius = radiusFactor * math.min(boxW, boxH); + for (var i = 0; i < n; i++) { + final dx = directions[i].dx.abs(); + final dy = directions[i].dy.abs(); + if (_radarLabelBeside(directions[i])) { + radius = math.min(radius, (boxW / 2 - gap - sizes[i].width) / dx); + if (dy > 0.01) { + radius = math.min(radius, (boxH / 2 - sizes[i].height / 2) / dy); + } + } else { + radius = math.min(radius, (boxH / 2 - gap - sizes[i].height) / dy); + if (dx > 0.01) { + radius = math.min(radius, (boxW / 2 - sizes[i].width / 2) / dx); + } + } + } + // Never let extreme labels crush the spider entirely; below this floor the + // labels get clamped (and ellipsized) instead. + final floor = 0.18 * math.min(boxW, boxH); + radius = radius.clamp( + math.min(floor, radiusFactor * math.min(boxW, boxH)), + radiusFactor * math.min(boxW, boxH), + ); + final chartSide = radius / radiusFactor; + + final center = Offset(boxW / 2, boxH / 2); + final rects = []; + final aligns = []; + for (var i = 0; i < n; i++) { + final dir = directions[i]; + final anchor = center + dir * (radius + gap); + var size = sizes[i]; + double left; + double top; + if (_radarLabelBeside(dir)) { + // Re-measure against the room actually left beside the polygon, so a + // clamped radius still produces a label that wraps inside the box. + final room = dir.dx > 0 ? boxW - anchor.dx : anchor.dx; + if (size.width > room) size = measure(spec.x[i], room); + left = dir.dx > 0 ? anchor.dx : anchor.dx - size.width; + top = anchor.dy - size.height / 2; + aligns.add(dir.dx > 0 ? TextAlign.left : TextAlign.right); + } else { + left = anchor.dx - size.width / 2; + top = dir.dy < 0 ? anchor.dy - size.height : anchor.dy; + aligns.add(TextAlign.center); + } + rects.add( + Rect.fromLTWH( + left.clamp(0.0, math.max(0.0, boxW - size.width)), + top.clamp(0.0, math.max(0.0, boxH - size.height)), + size.width, + size.height, + ), + ); + } + return ( + chartSide: chartSide, + rects: rects, + aligns: aligns, + maxLines: maxLines, + ); + } + + Widget _radarAxisLabel({ + required String label, + required int index, + required int count, + required ({ + double chartSide, + List rects, + List aligns, + int maxLines, + }) + layout, + required Color textColor, + }) { + final rect = layout.rects[index]; + return Positioned( + key: ValueKey('radar-axis-label-$index'), + left: rect.left, + top: rect.top, + width: rect.width, + height: rect.height, + child: Text( + label, + maxLines: layout.maxLines, + overflow: TextOverflow.ellipsis, + textAlign: layout.aligns[index], + style: _radarLabelStyle(count, textColor), + ), + ); + } + + /// Extract the touched real-series vertex from a radar touch response, + /// ignoring the invisible scale anchor dataset. + ({int series, int entry, double value, Offset offset})? _radarSpotFrom( + RadarTouchResponse? response, + ChartSpec spec, + ) { + final spot = response?.touchedSpot; + if (spot == null) return null; + if (spot.touchedDataSetIndex < 0 || + spot.touchedDataSetIndex >= spec.series.length) { + return null; // the anchor dataset, or out of range + } + return ( + series: spot.touchedDataSetIndex, + entry: spot.touchedRadarEntryIndex, + value: spot.touchedRadarEntry.value, + offset: spot.offset, + ); + } + + /// A small floating tooltip for the hovered radar vertex, like the other + /// charts: the axis label, the series name and the value. + Widget _radarTooltip( + ChartSpec spec, + double side, + ({int series, int entry, double value, Offset offset}) touch, + ) { + final axis = touch.entry >= 0 && touch.entry < spec.x.length + ? spec.x[touch.entry] + : ''; + final series = touch.series < spec.series.length + ? spec.series[touch.series].name + : ''; + final label = series.isEmpty ? 'Reeks ${touch.series + 1}' : series; + final onLeftHalf = touch.offset.dx <= side / 2; + return Positioned( + left: onLeftHalf ? (touch.offset.dx + w * 0.012) : null, + right: onLeftHalf ? null : (side - touch.offset.dx + w * 0.012), + top: (touch.offset.dy - w * 0.03).clamp(0.0, math.max(0.0, side - 1)), + child: IgnorePointer( + child: ConstrainedBox( + constraints: BoxConstraints(maxWidth: side * 0.6), + child: Container( + padding: EdgeInsets.symmetric( + horizontal: w * 0.012, + vertical: w * 0.006, + ), + decoration: BoxDecoration( + color: const Color(0xFF0F172A), + borderRadius: BorderRadius.circular(w * 0.008), + boxShadow: const [ + BoxShadow(color: Color(0x33000000), blurRadius: 6), + ], + ), + child: Text( + '${axis.isEmpty ? '' : '$axis\n'}$label: ${_fmtNum(touch.value)}', + maxLines: 3, + overflow: TextOverflow.ellipsis, + style: _tooltipStyle(), + ), + ), + ), + ), + ); + } + + /// Vertical scale legend shown to the right of a radar chart: the tick values + /// from the outer ring (top) down to the centre (bottom), in a small font. + Widget _radarScaleLegend( + ({double lo, double hi, int ticks}) scale, + Color textColor, + ) { + final style = _applyFont( + font, + TextStyle( + fontSize: w * 0.012 * _labelScale, + color: textColor.withValues(alpha: 0.62), + fontWeight: FontWeight.w600, + ), + ); + final tickColor = textColor.withValues(alpha: 0.3); + return Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (var k = scale.ticks; k >= 0; k--) ...[ + if (k != scale.ticks) SizedBox(height: w * 0.018 * _labelScale), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container(width: w * 0.012, height: 1, color: tickColor), + SizedBox(width: w * 0.006), + Flexible( + child: Text( + _fmtNum(scale.lo + (scale.hi - scale.lo) * k / scale.ticks), + style: style, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ], + ], + ); + } + + /// Resolves the radar scale: a low/high pair plus an even tick count. Honours + /// the optional [ChartSpec.minBound]/[maxBound] and otherwise rounds the data + /// range to a tidy scale so the rings read as round numbers. + ({double lo, double hi, int ticks}) radarScale(ChartSpec spec) { + var dataMin = 0.0; + var dataMax = 0.0; + var seen = false; + for (final s in spec.series) { + for (final v in s.data) { + if (!seen) { + dataMin = v; + dataMax = v; + seen = true; + } else { + if (v < dataMin) dataMin = v; + if (v > dataMax) dataMax = v; + } + } + } + if (!seen) { + dataMin = 0; + dataMax = 1; + } + final rawLo = spec.minBound ?? (dataMin < 0 ? dataMin : 0); + final rawHi = spec.maxBound ?? dataMax; + final nice = _niceScale(rawLo, rawHi); + final lo = spec.minBound ?? nice.lo; + var hi = spec.maxBound ?? nice.hi; + if (hi <= lo) hi = lo + nice.step; + final ticks = math.max(2, ((hi - lo) / nice.step).round()); + return (lo: lo, hi: hi, ticks: ticks); + } + + ({double lo, double hi, double step}) _niceScale(double lo, double hi) { + final range = (hi - lo).abs(); + final r = range <= 0 ? 1.0 : range; + final rawStep = r / 4; + final mag = math + .pow(10, (math.log(rawStep) / math.ln10).floor()) + .toDouble(); + final norm = rawStep / mag; + final niceNorm = norm < 1.5 + ? 1.0 + : norm < 3 + ? 2.0 + : norm < 7 + ? 5.0 + : 10.0; + final step = niceNorm * mag; + return ( + lo: (lo / step).floor() * step, + hi: (hi / step).ceil() * step, + step: step, + ); + } + + TextStyle _tooltipStyle() => _applyFont( + font, + TextStyle( + color: Colors.white, + fontSize: (w * 0.013 * _labelScale).clamp(11, 18), + height: 1.25, + fontWeight: FontWeight.w700, + ), + ); + + /// Tooltip style for line charts. Each touched dot adds two lines, so when + /// several dots overlap the font shrinks a step to keep the stack readable. + TextStyle _lineTooltipStyle(int count) { + final base = (w * 0.013 * _labelScale).clamp(11.0, 18.0); + final shrink = (1 - (count - 2) * 0.13).clamp(0.6, 1.0); + return _applyFont( + font, + TextStyle( + color: Colors.white, + fontSize: (base * shrink).clamp(8.0, 18.0), + height: 1.2, + fontWeight: FontWeight.w700, + ), + ); + } + + Widget _placeholder(BuildContext context) => + _placeholderText(context.l10n.d('Geen grafiekgegevens')); + + Widget _placeholderText(String text) => Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.bar_chart_outlined, + size: w * 0.08, + color: const Color(0xFF94A3B8), + ), + SizedBox(height: w * 0.01), + Text( + text, + style: TextStyle(color: const Color(0xFF94A3B8), fontSize: w * 0.02), + ), + ], + ), + ); +} + +class _HoverPieChart extends StatefulWidget { + final List values; + final List labels; + final List colors; + final double radius; + final double centerSpaceRadius; + final double sectionSpace; + final TextStyle titleStyle; + final TextStyle tooltipStyle; + + /// Slice index highlighted from outside (e.g. hovering the legend), combined + /// with this chart's own touch hover. + final int? externalHover; + + const _HoverPieChart({ + required this.values, + required this.labels, + required this.colors, + required this.radius, + required this.centerSpaceRadius, + required this.sectionSpace, + required this.titleStyle, + required this.tooltipStyle, + this.externalHover, + }); + + @override + State<_HoverPieChart> createState() => _HoverPieChartState(); +} + +class _HoverPieChartState extends State<_HoverPieChart> { + int? _hovered; + + @override + Widget build(BuildContext context) { + final total = widget.values.fold(0, (a, b) => a + b); + final external = widget.externalHover; + final hovered = + _hovered ?? + (external != null && external >= 0 && external < widget.values.length + ? external + : null); + return Stack( + clipBehavior: Clip.none, + children: [ + Positioned.fill( + child: PieChart( + PieChartData( + sections: [ + for (var i = 0; i < widget.values.length; i++) + PieChartSectionData( + value: widget.values[i], + color: widget.colors[i], + title: widget.values[i] / total >= 0.08 + ? '${(widget.values[i] / total * 100).round()}%' + : '', + radius: widget.radius * (hovered == i ? 1.08 : 1), + titleStyle: widget.titleStyle, + ), + ], + sectionsSpace: widget.sectionSpace, + centerSpaceRadius: widget.centerSpaceRadius, + pieTouchData: PieTouchData( + enabled: true, + mouseCursorResolver: (event, response) => + response?.touchedSection == null + ? SystemMouseCursors.basic + : SystemMouseCursors.click, + touchCallback: (event, response) { + final next = event.isInterestedForInteractions + ? response?.touchedSection?.touchedSectionIndex + : null; + if (next != _hovered) setState(() => _hovered = next); + }, + ), + ), + duration: Duration.zero, + ), + ), + if (hovered != null && hovered >= 0 && hovered < widget.values.length) + Positioned( + top: 4, + left: 4, + right: 4, + child: IgnorePointer( + child: Center( + child: Container( + key: const ValueKey('pie-hover-tooltip'), + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 6, + ), + decoration: BoxDecoration( + color: const Color(0xFF0F172A), + borderRadius: BorderRadius.circular(8), + boxShadow: const [ + BoxShadow(color: Color(0x33000000), blurRadius: 6), + ], + ), + child: Text( + '${widget.labels[hovered]}: ${_formatChartValue(widget.values[hovered])}', + maxLines: 2, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + style: widget.tooltipStyle, + ), + ), + ), + ), + ), + ], + ); + } +} + +String _formatChartValue(double value) => value == value.roundToDouble() + ? value.toInt().toString() + : value.toStringAsFixed(1); diff --git a/lib/widgets/slides/previews/checklist_previews.dart b/lib/widgets/slides/previews/checklist_previews.dart new file mode 100644 index 0000000..188a175 --- /dev/null +++ b/lib/widgets/slides/previews/checklist_previews.dart @@ -0,0 +1,333 @@ +// Part of the slide_preview library — see ../slide_preview.dart. +// Split out for navigability; all imports live in the main library file. +part of '../slide_preview.dart'; + +class _ChecklistProgress extends StatelessWidget { + final List bullets; + final double w; + final String font; + final ThemeProfile profile; + + const _ChecklistProgress({ + required this.bullets, + required this.w, + required this.font, + required this.profile, + }); + + @override + Widget build(BuildContext context) { + final items = bullets + .where((bullet) => checklistItemText(bullet).trim().isNotEmpty) + .toList(); + final checked = items.where(checklistItemChecked).length; + final total = items.length; + final checkedPercent = total == 0 ? 0 : ((checked / total) * 100).round(); + final openPercent = total == 0 ? 0 : 100 - checkedPercent; + final textColor = _hexColor(profile.textColor); + final checkedColor = _hexColor(profile.checklistCheckedColor); + final openColor = _hexColor(profile.checklistUncheckedColor); + final labelStyle = _applyFont( + font, + TextStyle( + fontSize: w * 0.0125, + height: 1.2, + color: textColor, + fontWeight: FontWeight.w600, + ), + ); + + final interaction = _ChecklistInteractionScope.maybeOf(context); + + return LayoutBuilder( + builder: (context, constraints) { + // Grow the pie to fill the width it is handed instead of staying at a + // fixed, tiny size. Every caller gives this widget a bounded column + // width, so the chart now scales with the space that is actually + // available next to (or above) the bullets. + final maxW = constraints.maxWidth.isFinite + ? constraints.maxWidth + : w * 0.4; + // Cap the pie so it stays a balanced companion to the bullet column + // rather than dominating it: a smaller chart keeps the visual split + // closer to 50/50 and, crucially, never forces the surrounding text to + // shrink to fit the chart's height when a slide has many bullets. + final diameter = maxW.clamp(w * 0.22, w * 0.30).toDouble(); + final baseRadius = diameter * 0.44; + final hoverRadius = diameter * 0.48; + final pieTitleStyle = _applyFont( + font, + TextStyle( + fontSize: diameter * 0.085, + height: 1.1, + fontWeight: FontWeight.bold, + color: textColor, + ), + ); + + Widget pie(bool? hovered) => PieChart( + key: const ValueKey('checklist-progress-pie'), + PieChartData( + sectionsSpace: w * 0.002, + centerSpaceRadius: 0, + startDegreeOffset: -90, + sections: [ + if (checkedPercent > 0) + PieChartSectionData( + value: checkedPercent.toDouble(), + color: checkedColor, + radius: hovered == true ? hoverRadius : baseRadius, + title: '$checkedPercent%', + titleStyle: pieTitleStyle.copyWith(color: Colors.white), + ), + if (openPercent > 0) + PieChartSectionData( + value: openPercent.toDouble(), + color: openColor, + radius: hovered == false ? hoverRadius : baseRadius, + title: '$openPercent%', + titleStyle: pieTitleStyle, + ), + ], + pieTouchData: PieTouchData( + enabled: interaction?.enabled == true, + touchCallback: (event, response) { + if (interaction?.enabled != true) return; + final index = event.isInterestedForInteractions + ? response?.touchedSection?.touchedSectionIndex + : null; + if (index == null) { + interaction!.hovered.value = null; + } else if (checkedPercent == 0) { + interaction!.hovered.value = false; + } else { + interaction!.hovered.value = index == 0; + } + }, + ), + ), + duration: Duration.zero, + ); + + return Semantics( + label: + '${context.l10n.d('Afgevinkt')} $checkedPercent%, ' + '${context.l10n.d('Niet afgevinkt')} $openPercent%', + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + width: diameter, + height: diameter, + child: interaction == null + ? pie(null) + : ValueListenableBuilder( + valueListenable: interaction.hovered, + builder: (_, hovered, _) => pie(hovered), + ), + ), + SizedBox(height: w * 0.008), + MouseRegion( + key: const ValueKey('checklist-progress-checked'), + onEnter: interaction?.enabled != true + ? null + : (_) => interaction!.hovered.value = true, + onExit: interaction?.enabled != true + ? null + : (_) => interaction!.hovered.value = null, + child: Text( + '${context.l10n.d('Afgevinkt')} $checkedPercent%', + style: labelStyle, + ), + ), + MouseRegion( + key: const ValueKey('checklist-progress-unchecked'), + onEnter: interaction?.enabled != true + ? null + : (_) => interaction!.hovered.value = false, + onExit: interaction?.enabled != true + ? null + : (_) => interaction!.hovered.value = null, + child: Text( + '${context.l10n.d('Niet afgevinkt')} $openPercent%', + style: labelStyle.copyWith( + color: textColor.withValues(alpha: 0.7), + ), + ), + ), + ], + ), + ); + }, + ); + } +} + +class _ChecklistBulletRow extends StatelessWidget { + final List bullets; + final int itemIndex; + final int column; + final ListStyle listStyle; + final bool checked; + final String text; + final int level; + final double fontSize; + final double bulletSize; + final double bulletGap; + final double scale; + final String font; + final ThemeProfile profile; + + const _ChecklistBulletRow({ + required this.bullets, + required this.itemIndex, + required this.column, + required this.listStyle, + required this.checked, + required this.text, + required this.level, + required this.fontSize, + required this.bulletSize, + required this.bulletGap, + required this.scale, + required this.font, + required this.profile, + }); + + @override + Widget build(BuildContext context) { + final interaction = _ChecklistInteractionScope.maybeOf(context); + Widget row(bool highlighted) => AnimatedContainer( + key: ValueKey('checklist-preview-item-$column-$itemIndex'), + duration: const Duration(milliseconds: 140), + padding: EdgeInsets.symmetric(horizontal: highlighted ? wScale(6) : 0), + decoration: BoxDecoration( + color: highlighted + ? _hexColor(profile.accentColor).withValues(alpha: 0.16) + : Colors.transparent, + borderRadius: BorderRadius.circular(wScale(5)), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + GestureDetector( + key: ValueKey('checklist-preview-toggle-$column-$itemIndex'), + behavior: HitTestBehavior.opaque, + onTap: + listStyle == ListStyle.checklist && interaction?.enabled == true + ? () => interaction!.onToggle?.call(column, itemIndex) + : null, + child: MouseRegion( + cursor: + listStyle == ListStyle.checklist && + interaction?.enabled == true + ? SystemMouseCursors.click + : MouseCursor.defer, + child: Text( + '${_listMarker(bullets, itemIndex, listStyle)} ', + style: TextStyle( + fontSize: fontSize, + color: _hexColor(profile.accentColor), + fontWeight: FontWeight.bold, + ), + ), + ), + ), + Expanded( + child: _md( + context, + text, + _applyFont( + font, + TextStyle( + fontSize: fontSize, + height: _kBulletLineHeight, + color: _hexColor(profile.textColor), + decoration: checked && profile.checklistStrikeThrough + ? TextDecoration.lineThrough + : null, + decorationColor: _hexColor(profile.textColor), + ), + ), + linkColor: _hexColor(profile.accentColor), + ), + ), + ], + ), + ); + + final padded = Padding( + padding: EdgeInsets.only( + left: level * bulletSize * 1.05 * scale, + top: bulletGap * scale, + bottom: bulletGap * scale, + ), + child: interaction == null || listStyle != ListStyle.checklist + ? row(false) + : ValueListenableBuilder( + valueListenable: interaction.hovered, + builder: (_, hovered, _) => row(hovered == checked), + ), + ); + return padded; + } + + double wScale(double value) => value * scale; +} + +class _ChecklistInteractionHost extends StatefulWidget { + final bool enabled; + final void Function(int column, int itemIndex)? onToggle; + final Widget child; + + const _ChecklistInteractionHost({ + required this.enabled, + required this.onToggle, + required this.child, + }); + + @override + State<_ChecklistInteractionHost> createState() => + _ChecklistInteractionHostState(); +} + +class _ChecklistInteractionHostState extends State<_ChecklistInteractionHost> { + final ValueNotifier hovered = ValueNotifier(null); + + @override + void dispose() { + hovered.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return _ChecklistInteractionScope( + enabled: widget.enabled, + hovered: hovered, + onToggle: widget.onToggle, + child: widget.child, + ); + } +} + +class _ChecklistInteractionScope extends InheritedWidget { + final bool enabled; + final ValueNotifier hovered; + final void Function(int column, int itemIndex)? onToggle; + + const _ChecklistInteractionScope({ + required this.enabled, + required this.hovered, + required this.onToggle, + required super.child, + }); + + static _ChecklistInteractionScope? maybeOf(BuildContext context) => + context.dependOnInheritedWidgetOfExactType<_ChecklistInteractionScope>(); + + @override + bool updateShouldNotify(_ChecklistInteractionScope oldWidget) => + enabled != oldWidget.enabled || onToggle != oldWidget.onToggle; +} diff --git a/lib/widgets/slides/previews/code_preview.dart b/lib/widgets/slides/previews/code_preview.dart new file mode 100644 index 0000000..e0f00a8 --- /dev/null +++ b/lib/widgets/slides/previews/code_preview.dart @@ -0,0 +1,180 @@ +// Part of the slide_preview library — see ../slide_preview.dart. +// Split out for navigability; all imports live in the main library file. +part of '../slide_preview.dart'; + +/// Een 'broncode-sheet': de code op een donker editor-vlak, met +/// syntaxkleuring wanneer een taal bekend is. De tekst blijft platte tekst maar +/// wordt monospace en gekleurd weergegeven. Past zich met een FittedBox aan de +/// slide aan zodat lange fragmenten netjes verkleinen i.p.v. af te kappen. +class _CodePreview extends StatelessWidget { + final Slide slide; + final double w; + final String font; + final ThemeProfile profile; + + const _CodePreview({ + required this.slide, + required this.w, + required this.font, + required this.profile, + }); + + /// Natural (unwrapped) size of [text] in [style]: width is the longest line, + /// height the full block. Used to scale code to the available space. + static Size _measureMono(String text, TextStyle style) { + final painter = TextPainter( + text: TextSpan(text: text.isEmpty ? ' ' : text, style: style), + textDirection: TextDirection.ltr, + )..layout(); + return painter.size; + } + + @override + Widget build(BuildContext context) { + _ensureHighlightLanguages(); + final pad = w * 0.05; + final safe = slide.showLogo ? _logoSafeInsets(w, profile) : EdgeInsets.zero; + final code = slide.customMarkdown; + final lang = slide.codeLanguage.trim(); + final known = lang.isNotEmpty && allLanguages.containsKey(lang); + + final codeBg = _hexColor(profile.codeBackgroundColor); + final codeFg = _hexColor(profile.codeTextColor); + + // The chosen monospace family, always backed by a generic monospace fallback + // so an uninstalled face still renders fixed-width. + final fallback = ['Menlo', 'Consolas', 'Courier New', 'monospace'] + ..removeWhere((f) => f == profile.codeFontFamily); + final baseFont = w * 0.024; + final maxFont = w * 0.040; // grow to fill, but never huge + TextStyle monoAt(double size) => TextStyle( + fontFamily: profile.codeFontFamily, + fontFamilyFallback: fallback, + fontSize: size, + height: 1.4, + color: codeFg, + ); + + // HighlightView throws on an unknown language, so fall back to plain (but + // monospace) text. When syntax highlighting is off we always render plain + // text so the whole block is one colour — needed for a CRT-green screen. + final useHighlight = known && profile.codeHighlightSyntax; + final highlightTheme = { + ...atomOneDarkTheme, + // Keep atom-one-dark's per-token colours but drop its own background so + // our themed [codeBg] shows through unchanged. + 'root': (atomOneDarkTheme['root'] ?? const TextStyle()).copyWith( + backgroundColor: codeBg, + color: codeFg, + ), + }; + Widget buildCode(TextStyle style) => useHighlight + ? HighlightView( + code, + language: lang, + theme: highlightTheme, + padding: EdgeInsets.zero, + textStyle: style, + ) + : Text(code, style: style); + + return Container( + color: _hexColor(profile.slideBackgroundColor), + child: Padding( + padding: EdgeInsets.fromLTRB( + pad, + pad + safe.top, + pad, + pad + safe.bottom, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // The slide title belongs to the slide, not inside the code window, + // so it sits above the panel like other slide types. + if (slide.title.isNotEmpty) ...[ + Container( + width: double.infinity, + padding: EdgeInsets.symmetric( + horizontal: w * 0.025, + vertical: w * 0.01, + ), + decoration: BoxDecoration( + color: _hexColor(profile.titleBackgroundColor), + borderRadius: BorderRadius.circular(w * 0.012), + border: Border( + left: BorderSide( + color: _hexColor(profile.accentColor), + width: w * 0.006, + ), + ), + ), + child: _md( + context, + slide.title, + _applyFont( + font, + TextStyle( + fontSize: w * 0.032, + height: 1.1, + fontWeight: FontWeight.bold, + color: _hexColor(profile.titleTextColor), + ), + ), + linkColor: _hexColor(profile.accentColor), + ), + ), + SizedBox(height: w * 0.018), + ], + Expanded( + child: Container( + width: double.infinity, + decoration: BoxDecoration( + color: codeBg, + borderRadius: BorderRadius.circular(w * 0.012), + border: Border.all(color: codeFg.withValues(alpha: 0.22)), + ), + padding: EdgeInsets.all(w * 0.03), + child: LayoutBuilder( + builder: (context, constraints) { + // Size the code to fill the panel: scale up to use spare + // space (capped at [maxFont]) and down so long fragments + // still fit, rather than leaving a small block in a big box. + final measured = useHighlight + ? code.replaceAll('\t', ' ') + : code; + final natural = _measureMono(measured, monoAt(baseFont)); + final availW = math.max(1.0, constraints.maxWidth - 1); + final availH = math.max(1.0, constraints.maxHeight - 1); + var scale = math.min( + availW / natural.width, + availH / natural.height, + ); + if (!scale.isFinite || scale <= 0) scale = 1; + final size = math.min(baseFont * scale, maxFont); + return Align( + alignment: Alignment.topLeft, + child: buildCode(monoAt(size)), + ); + }, + ), + ), + ), + ], + ), + ), + ); + } +} + +/// Register highlight.js language definitions once, so [HighlightView] can +/// colour any common language without throwing. +bool _highlightReady = false; + +void _ensureHighlightLanguages() { + if (_highlightReady) return; + allLanguages.forEach(highlight.registerLanguage); + _highlightReady = true; +} + +// ── Shared helper ───────────────────────────────────────────────────────────── diff --git a/lib/widgets/slides/previews/media_previews.dart b/lib/widgets/slides/previews/media_previews.dart new file mode 100644 index 0000000..3df31fc --- /dev/null +++ b/lib/widgets/slides/previews/media_previews.dart @@ -0,0 +1,601 @@ +// Part of the slide_preview library — see ../slide_preview.dart. +// Split out for navigability; all imports live in the main library file. +part of '../slide_preview.dart'; + +class _AudioPlayback extends StatefulWidget { + final String audioPath; + final String? projectPath; + final bool autoplay; + final double w; + final VoidCallback? onComplete; + + const _AudioPlayback({ + required this.audioPath, + required this.projectPath, + required this.autoplay, + required this.w, + this.onComplete, + }); + + @override + State<_AudioPlayback> createState() => _AudioPlaybackState(); +} + +class _AudioPlaybackState extends State<_AudioPlayback> { + VideoPlayerController? _controller; + bool _completed = false; + + @override + void initState() { + super.initState(); + _init(); + } + + @override + void didUpdateWidget(_AudioPlayback oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.audioPath != widget.audioPath || + oldWidget.autoplay != widget.autoplay) { + _init(); + } + } + + Future _init() async { + _controller?.removeListener(_onTick); + await _controller?.dispose(); + _completed = false; + final path = _resolvePath(widget.audioPath, widget.projectPath); + if (path == null) return; + final controller = VideoPlayerController.file(File(path)); + _controller = controller; + try { + await controller.initialize(); + controller.addListener(_onTick); + if (widget.autoplay) await controller.play(); + } catch (e) { + logWarning('_AudioPlaybackState._init: audio controller init failed', e); + } + if (mounted) setState(() {}); + } + + /// Detecteer het einde van de audio en meld dat één keer (voor auto-advance). + void _onTick() { + final c = _controller; + if (c == null || !c.value.isInitialized || _completed) return; + final pos = c.value.position; + final dur = c.value.duration; + if (dur > Duration.zero && + pos.inMilliseconds >= dur.inMilliseconds - 200 && + !c.value.isPlaying) { + _completed = true; + widget.onComplete?.call(); + } + } + + @override + void dispose() { + _controller?.removeListener(_onTick); + _controller?.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final controller = _controller; + return Positioned( + right: widget.w * 0.035, + bottom: widget.w * 0.035, + child: IconButton( + tooltip: 'Audio', + onPressed: controller == null || !controller.value.isInitialized + ? null + : () { + setState(() { + controller.value.isPlaying + ? controller.pause() + : controller.play(); + }); + }, + icon: Icon( + controller?.value.isPlaying == true + ? Icons.volume_up + : Icons.volume_up_outlined, + ), + iconSize: widget.w * 0.032, + ), + ); + } +} + +// ── Individual slide-type renderers ────────────────────────────────────────── + +class _TwoImagesPreview extends StatelessWidget { + final Slide slide; + final double w; + final String? projectPath; + final String font; + final ThemeProfile profile; + + const _TwoImagesPreview({ + required this.slide, + required this.w, + this.projectPath, + required this.font, + required this.profile, + }); + + @override + Widget build(BuildContext context) { + final splitFraction = (slide.imageSize > 0 ? slide.imageSize / 100.0 : 0.5) + .clamp(0.1, 0.9); + final leftW = w * splitFraction; + final rightW = w * (1 - splitFraction); + final titleSize = w * 0.032; + + return Container( + color: _hexColor(profile.slideBackgroundColor), + child: Stack( + fit: StackFit.expand, + children: [ + // Twee afbeeldingen naast elkaar + Row( + children: [ + SizedBox( + width: leftW, + child: Stack( + fit: StackFit.expand, + children: [ + _resolvedImage(context, slide.imagePath, projectPath), + _captionOverlay(context, slide.imageCaption, w), + ], + ), + ), + SizedBox( + width: rightW, + child: Stack( + fit: StackFit.expand, + children: [ + _resolvedImage(context, slide.imagePath2, projectPath), + _captionOverlay(context, slide.imageCaption2, w), + ], + ), + ), + ], + ), + // Optionele ondertitel + if (slide.title.isNotEmpty) + Positioned( + left: 0, + right: 0, + bottom: w * 0.04, + child: Container( + color: Colors.black54, + padding: EdgeInsets.symmetric( + horizontal: w * 0.04, + vertical: w * 0.015, + ), + child: _md( + context, + slide.title, + _applyFont( + font, + TextStyle( + color: Colors.white, + fontSize: titleSize, + fontWeight: FontWeight.w500, + ), + ), + linkColor: const Color(0xFF8BB8FF), + textAlign: TextAlign.center, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ), + ), + ], + ), + ); + } +} + +class _ImagePreview extends StatelessWidget { + final Slide slide; + final double w; + final String? projectPath; + final String font; + final ThemeProfile profile; + + const _ImagePreview({ + required this.slide, + required this.w, + this.projectPath, + required this.font, + required this.profile, + }); + + @override + Widget build(BuildContext context) { + final hasTitle = slide.title.isNotEmpty; + return Stack( + fit: StackFit.expand, + children: [ + _zoomedImage( + context, + slide.imagePath, + projectPath, + slide.imageSize, + bgColor: _hexColor(profile.slideBackgroundColor), + // When zoomed out, anchor the image to the top so the bottom title + // banner sits in the freed-up space instead of over the picture. + alignment: hasTitle ? Alignment.topCenter : Alignment.center, + ), + if (slide.title.isNotEmpty) + Positioned( + left: w * 0.06, + right: w * 0.06, + bottom: w * 0.06, + child: Container( + padding: EdgeInsets.symmetric( + horizontal: w * 0.04, + vertical: w * 0.02, + ), + decoration: BoxDecoration( + color: Colors.black54, + borderRadius: BorderRadius.circular(4), + ), + child: _md( + context, + slide.title, + _applyFont( + font, + TextStyle( + color: Colors.white, + fontSize: w * 0.038, + fontWeight: FontWeight.bold, + ), + ), + linkColor: const Color(0xFF8BB8FF), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ), + ), + _captionOverlay(context, slide.imageCaption, w), + ], + ); + } +} + +class _VideoPreview extends StatefulWidget { + final Slide slide; + final double w; + final String? projectPath; + final String font; + final ThemeProfile profile; + final bool autoplay; + final VoidCallback? onComplete; + + const _VideoPreview({ + required this.slide, + required this.w, + this.projectPath, + required this.font, + required this.profile, + this.autoplay = false, + this.onComplete, + }); + + @override + State<_VideoPreview> createState() => _VideoPreviewState(); +} + +class _VideoPreviewState extends State<_VideoPreview> { + VideoPlayerController? _controller; + String? _path; + bool _completed = false; + + @override + void initState() { + super.initState(); + _init(); + } + + @override + void didUpdateWidget(_VideoPreview oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.slide.videoPath != widget.slide.videoPath || + oldWidget.autoplay != widget.autoplay) { + _init(); + } + } + + Future _init() async { + _controller?.removeListener(_onTick); + await _controller?.dispose(); + _controller = null; + _completed = false; + _path = _resolvePath(widget.slide.videoPath, widget.projectPath); + if (_path == null) { + if (mounted) setState(() {}); + return; + } + final controller = VideoPlayerController.file(File(_path!)); + _controller = controller; + try { + await controller.initialize(); + controller.addListener(_onTick); + await controller.setLooping(false); + if (widget.autoplay) await controller.play(); + } catch (e) { + logWarning('_VideoPreviewState._init: video controller init failed', e); + // Keep the placeholder visible when the platform cannot open the file. + } + if (mounted) setState(() {}); + } + + void _onTick() { + final controller = _controller; + if (controller == null || + !controller.value.isInitialized || + _completed || + !widget.autoplay) { + return; + } + final duration = controller.value.duration; + final position = controller.value.position; + if (duration > Duration.zero && + position.inMilliseconds >= duration.inMilliseconds - 200 && + !controller.value.isPlaying) { + _completed = true; + widget.onComplete?.call(); + } + } + + @override + void dispose() { + _controller?.removeListener(_onTick); + _controller?.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final controller = _controller; + return Container( + color: _hexColor(widget.profile.slideBackgroundColor), + child: Stack( + fit: StackFit.expand, + children: [ + if (controller != null && controller.value.isInitialized) + Center( + child: AspectRatio( + aspectRatio: controller.value.aspectRatio, + child: VideoPlayer(controller), + ), + ) + else + _mediaPlaceholder(Icons.movie_outlined, 'Video'), + if (widget.slide.title.isNotEmpty) + Positioned( + left: widget.w * 0.06, + right: widget.w * 0.06, + top: widget.w * 0.04, + child: _md( + context, + widget.slide.title, + _applyFont( + widget.font, + TextStyle( + color: _hexColor(widget.profile.textColor), + fontSize: widget.w * 0.038, + fontWeight: FontWeight.bold, + ), + ), + linkColor: _hexColor(widget.profile.accentColor), + ), + ), + Positioned( + left: widget.w * 0.04, + bottom: widget.w * 0.035, + child: IconButton( + onPressed: controller == null || !controller.value.isInitialized + ? null + : () { + setState(() { + controller.value.isPlaying + ? controller.pause() + : controller.play(); + }); + }, + icon: Icon( + controller?.value.isPlaying == true + ? Icons.pause_circle + : Icons.play_circle, + ), + iconSize: widget.w * 0.045, + ), + ), + ], + ), + ); + } +} + +/// Rendert een afbeelding met zoomfactor op basis van BoxFit.contain. +/// imageSize = 0 → cover (Marp-standaard, vult frame, snijdt bij) +/// imageSize = 100 → volledige afbeelding zichtbaar (contain, evt. randen) +/// imageSize > 100 → inzoomen: groter dan contain, bijgesneden door ClipRect +/// imageSize < 100 → nog meer uitzoomen: afbeelding kleiner dan contain +Widget _zoomedImage( + BuildContext context, + String imagePath, + String? projectPath, + int imageSize, { + Color bgColor = Colors.black, + Alignment alignment = Alignment.center, +}) { + if (imageSize == 0) { + return _resolvedImage( + context, + imagePath, + projectPath, + ); // BoxFit.cover standaard + } + final scale = imageSize / 100.0; + // Size the image box to `scale` × the available area and let BoxFit.contain + // fit the picture inside it. This produces the same visual result as a + // Transform.scale but without a transform layer, which `RepaintBoundary + // .toImage` (used for exports) captures far more reliably — a scaled + // transform layer would frequently render blank in the exported PNG. + return ClipRect( + child: ColoredBox( + color: bgColor, + child: LayoutBuilder( + builder: (context, constraints) { + final boxW = constraints.maxWidth * scale; + final boxH = constraints.maxHeight * scale; + return Align( + alignment: alignment, + child: SizedBox( + width: boxW, + height: boxH, + // BoxFit.contain: toont de volledige afbeelding zonder bijsnijden + child: _resolvedImage( + context, + imagePath, + projectPath, + fit: BoxFit.contain, + ), + ), + ); + }, + ), + ), + ); +} + +Widget _resolvedImage( + BuildContext context, + String imagePath, + String? projectPath, { + BoxFit fit = BoxFit.cover, +}) { + if (imagePath.isEmpty) return _imagePlaceholder(context); + + final String resolved; + if (imagePath.startsWith('/') || imagePath.contains(':\\')) { + resolved = imagePath; + } else if (projectPath != null) { + resolved = '$projectPath/$imagePath'; + } else { + resolved = imagePath; + } + + return Image.file( + File(resolved), + fit: fit, + width: double.infinity, + height: double.infinity, + // Keep showing the previous frame while the next image decodes. Without + // this the widget paints nothing for a frame on a source change, which + // shows up as a black flash between slides — fatal when recording video. + gaplessPlayback: true, + errorBuilder: (context, error, stackTrace) => _imagePlaceholder(context), + ); +} + +Widget _captionOverlay( + BuildContext context, + String caption, + double w, { + double? right, + double? bottom, +}) { + final text = caption.trim(); + if (text.isEmpty) return const SizedBox.shrink(); + // Een copyright/bijschrift staat rechtsonder; als daar een TLP-markering + // staat, schuift het bijschrift erboven zodat het niet wordt overschreven. + final lift = _SlideLinkScope.hasBottomTlpOf(context) + ? _tlpVerticalReserve(w) + : 0.0; + return Positioned( + right: right ?? w * _kTlpEdge, + bottom: (bottom ?? _tlpBottomInset(w)) + lift, + child: Container( + constraints: BoxConstraints(maxWidth: w * 0.5), + padding: EdgeInsets.symmetric(horizontal: w * 0.008, vertical: w * 0.005), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.58), + borderRadius: BorderRadius.circular(3), + ), + child: Text( + text, + textAlign: TextAlign.right, + style: TextStyle( + color: Colors.white, + fontSize: w * 0.011, + height: 1.25, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ), + ); +} + +Widget _mediaPlaceholder(IconData icon, String label) { + return Container( + color: const Color(0xFFE2E8F0), + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, color: const Color(0xFF94A3B8), size: 32), + const SizedBox(height: 6), + Text( + label, + style: const TextStyle(color: Color(0xFF94A3B8), fontSize: 12), + ), + ], + ), + ), + ); +} + +Widget _imagePlaceholder(BuildContext context) { + return ColoredBox( + color: const Color(0xFFE2E8F0), + child: LayoutBuilder( + builder: (context, constraints) { + final shortestSide = constraints.biggest.shortestSide; + if (shortestSide < 48) { + return Center( + child: Icon( + Icons.image_outlined, + color: const Color(0xFF94A3B8), + size: shortestSide * 0.65, + ), + ); + } + + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.image_outlined, + color: Color(0xFF94A3B8), + size: 24, + ), + const SizedBox(height: 4), + Text( + context.l10n.d('Afbeelding'), + style: const TextStyle(color: Color(0xFF94A3B8), fontSize: 10), + ), + ], + ), + ); + }, + ), + ); +} diff --git a/lib/widgets/slides/previews/overlays.dart b/lib/widgets/slides/previews/overlays.dart new file mode 100644 index 0000000..8f9394b --- /dev/null +++ b/lib/widgets/slides/previews/overlays.dart @@ -0,0 +1,246 @@ +// Part of the slide_preview library — see ../slide_preview.dart. +// Split out for navigability; all imports live in the main library file. +part of '../slide_preview.dart'; + +class _LogoOverlay extends StatelessWidget { + final String logoPath; + final String? projectPath; + final String position; + final double size; + + const _LogoOverlay({ + required this.logoPath, + required this.projectPath, + required this.position, + required this.size, + }); + + @override + Widget build(BuildContext context) { + final horizontalInset = size * 0.28; + final topInset = size * 0.42; + final bottomInset = size * 0.12; + return Positioned( + top: position.startsWith('top') ? topInset : null, + bottom: position.startsWith('bottom') ? bottomInset : null, + left: position.endsWith('left') ? horizontalInset : null, + right: position.endsWith('right') ? horizontalInset : null, + child: SizedBox( + width: size, + height: size, + child: _resolvedImage( + context, + logoPath, + projectPath, + fit: BoxFit.contain, + ), + ), + ); + } +} + +// ── TLP-markering: maten gedeeld door de badge en de footer-uitsparing ────── +const double _kTlpFont = 0.018; // × slidebreedte + +const double _kTlpEdge = 0.025; // afstand tot de slidehoek (× breedte) + +const double _kTlpHPad = 0.011; + +const double _kTlpVPad = 0.005; + +double _tlpBottomInset(double w) => w * 0.022; + +/// Geschatte breedte van de TLP-badge, zodat de footer ervoor kan uitwijken. +double _tlpBadgeWidth(double w, TlpLevel tlp) => + tlp.label.length * w * _kTlpFont * 0.62 + 2 * (w * _kTlpHPad); + +/// Verticale ruimte die een TLP-badge rechtsonder inneemt (voor bijschriften). +double _tlpVerticalReserve(double w) => + w * _kTlpFont + 2 * (w * _kTlpVPad) + _tlpBottomInset(w); + +/// Officiële TLP 2.0-markering (FIRST): de gekleurde label op een zwart vlak, +/// rechtsonder. Wijkt uit naar linksonder als het logo rechtsonder staat. +class _TlpOverlay extends StatelessWidget { + final TlpLevel tlp; + final double w; + final ThemeProfile profile; + final bool hasLogo; + + const _TlpOverlay({ + required this.tlp, + required this.w, + required this.profile, + required this.hasLogo, + }); + + @override + Widget build(BuildContext context) { + final toLeft = hasLogo && profile.logoPosition == 'bottom-right'; + return Positioned( + bottom: _tlpBottomInset(w), + left: toLeft ? w * _kTlpEdge : null, + right: toLeft ? null : w * _kTlpEdge, + child: Container( + padding: EdgeInsets.symmetric( + horizontal: w * _kTlpHPad, + vertical: w * _kTlpVPad, + ), + decoration: BoxDecoration( + color: Colors.black, + borderRadius: BorderRadius.circular(w * 0.005), + ), + child: Text( + tlp.label, + style: TextStyle( + color: Color(tlp.foreground), + fontSize: w * _kTlpFont, + fontWeight: FontWeight.w700, + letterSpacing: 0.4, + fontFamily: 'monospace', + fontFamilyFallback: const ['Menlo', 'Consolas', 'Courier New'], + height: 1.0, + ), + ), + ), + ); + } +} + +class _FooterOverlay extends StatelessWidget { + final Slide slide; + final double w; + final ThemeProfile profile; + final int? slideNumber; + final int? slideCount; + final TlpLevel tlp; + + const _FooterOverlay({ + required this.slide, + required this.w, + required this.profile, + this.slideNumber, + this.slideCount, + this.tlp = TlpLevel.none, + }); + + String _applyTokens(String s) { + final now = DateTime.now(); + String two(int v) => v.toString().padLeft(2, '0'); + final date = '${two(now.day)}-${two(now.month)}-${now.year}'; + return s + .replaceAll('{page}', slideNumber?.toString() ?? '') + .replaceAll('{total}', slideCount?.toString() ?? '') + .replaceAll('{date}', date) + .replaceAll('{title}', slide.title); + } + + @override + Widget build(BuildContext context) { + if (!slide.showFooter) return const SizedBox.shrink(); + if (slide.type == SlideType.title || slide.type == SlideType.section) { + return const SizedBox.shrink(); + } + + final footerText = _applyTokens(profile.footerText).trim(); + final showPages = profile.footerShowPageNumbers && slideNumber != null; + if (footerText.isEmpty && !showPages) return const SizedBox.shrink(); + + // Iets kleiner dan voorheen, zodat 'ie niet tegen het logo aan drukt. + final fontSize = w * 0.0145; + final style = TextStyle( + color: _hexColor(profile.textColor).withValues(alpha: 0.7), + fontSize: fontSize, + // Lichte schaduw zodat de footer ook over een afbeelding leesbaar blijft. + shadows: [ + Shadow( + color: Colors.white.withValues(alpha: 0.5), + blurRadius: w * 0.003, + ), + ], + ); + + // Onderhoeken vrijhouden: de footer wijkt horizontaal uit voor het logo en + // de TLP-badge, zodat ze netjes uitlijnen i.p.v. over elkaar te vallen. + double mx(double a, double b) => a > b ? a : b; + final hasLogo = profile.logoPath?.isNotEmpty == true && slide.showLogo; + final logoBottom = hasLogo && profile.logoPosition.startsWith('bottom'); + final logoOnLeft = profile.logoPosition.endsWith('left'); + final logoSpan = w * (profile.logoSize / 1280) * 1.28 + w * 0.012; + final logoLeftEdge = w * (profile.logoSize / 1280) * 0.28; + final tlpOnRight = !(hasLogo && profile.logoPosition == 'bottom-right'); + final tlpSpan = tlp == TlpLevel.none + ? 0.0 + : w * _kTlpEdge + _tlpBadgeWidth(w, tlp) + w * 0.012; + final footerLeftAligned = profile.footerPosition == 'left'; + + // Links uitgelijnd begint de footer waar het logo of de bullets beginnen, + // voor een consistente linkermarge. Anders de standaardmarge. + var left = footerLeftAligned + ? (logoBottom && logoOnLeft + ? logoLeftEdge + : _contentLeftInset(slide, w)) + : w * 0.04; + var right = w * 0.04; + if (logoBottom) { + if (logoOnLeft) { + // Een links-uitgelijnde footer mag bewust met de logo-linkerkant + // uitlijnen; anders schuift 'ie rechts van het logo om overlap te + // voorkomen. + if (!footerLeftAligned) left = mx(left, logoSpan); + } else { + right = mx(right, logoSpan); + } + } + if (tlp != TlpLevel.none) { + if (tlpOnRight) { + right = mx(right, tlpSpan); + } else { + left = mx(left, tlpSpan); + } + } + + final alignment = switch (profile.footerPosition) { + 'left' => Alignment.centerLeft, + 'center' => Alignment.center, + _ => Alignment.centerRight, + }; + final textAlign = switch (profile.footerPosition) { + 'left' => TextAlign.left, + 'center' => TextAlign.center, + _ => TextAlign.right, + }; + + return Positioned( + left: left, + right: right, + bottom: w * 0.02, + child: Align( + alignment: alignment, + child: ConstrainedBox( + constraints: BoxConstraints(maxWidth: w - left - right), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (footerText.isNotEmpty) + Flexible( + child: Text( + footerText, + style: style, + textAlign: textAlign, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + if (footerText.isNotEmpty && showPages) SizedBox(width: w * 0.02), + if (showPages) + Text( + '$slideNumber / ${slideCount ?? slideNumber}', + style: style, + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/widgets/slides/previews/table_preview.dart b/lib/widgets/slides/previews/table_preview.dart new file mode 100644 index 0000000..6611473 --- /dev/null +++ b/lib/widgets/slides/previews/table_preview.dart @@ -0,0 +1,129 @@ +// Part of the slide_preview library — see ../slide_preview.dart. +// Split out for navigability; all imports live in the main library file. +part of '../slide_preview.dart'; + +class _TablePreview extends StatelessWidget { + final Slide slide; + final double w; + final String font; + final ThemeProfile profile; + + const _TablePreview({ + required this.slide, + required this.w, + required this.font, + required this.profile, + }); + + @override + Widget build(BuildContext context) { + final pad = w * 0.06; + final safe = slide.showLogo + ? _splitTextLogoSafeInsets(w, profile) + : EdgeInsets.zero; + final titleSize = w * 0.038; + final rows = slide.tableRows.where((r) => r.isNotEmpty).toList(); + final colCount = rows.fold(0, (m, r) => r.length > m ? r.length : m); + + // Scale cell text down as the table grows so it keeps fitting nicely. + final density = (rows.length + colCount).clamp(2, 24); + final cellSize = (w * 0.025 * (10 / (density + 6))).clamp( + w * 0.010, + w * 0.021, + ); + + final accent = _hexColor(profile.accentColor); + final textColor = _hexColor(profile.tableTextColor); + final headerTextColor = _hexColor(profile.tableHeaderTextColor); + final borderColor = accent.withValues(alpha: 0.35); + + Widget cell(String value, {required bool header}) { + return Padding( + padding: EdgeInsets.symmetric( + horizontal: cellSize * 0.55, + vertical: cellSize * 0.36, + ), + child: _md( + context, + value, + _applyFont( + font, + TextStyle( + fontSize: cellSize, + color: header ? headerTextColor : textColor, + fontWeight: header ? FontWeight.bold : FontWeight.normal, + ), + ), + linkColor: header ? headerTextColor : accent, + ), + ); + } + + TableRow buildRow(List row, {required bool header}) { + return TableRow( + decoration: BoxDecoration(color: header ? accent : null), + children: List.generate(colCount, (c) { + final value = c < row.length ? row[c] : ''; + return TableCell( + verticalAlignment: TableCellVerticalAlignment.middle, + child: cell(value, header: header), + ); + }), + ); + } + + return Container( + color: _hexColor(profile.slideBackgroundColor), + child: FittedBox( + fit: BoxFit.scaleDown, + alignment: Alignment.topLeft, + child: SizedBox( + width: w, + child: Padding( + padding: EdgeInsets.fromLTRB( + pad, + pad + safe.top, + pad, + pad + safe.bottom, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + if (slide.title.isNotEmpty) ...[ + _md( + context, + slide.title, + _applyFont( + font, + TextStyle( + fontSize: titleSize, + fontWeight: FontWeight.bold, + color: _hexColor(profile.textColor), + ), + ), + linkColor: _hexColor(profile.accentColor), + ), + SizedBox(height: pad * 0.35), + ], + if (rows.isNotEmpty && colCount > 0) + Table( + border: TableBorder.all( + color: borderColor, + width: w * 0.0012, + ), + defaultColumnWidth: const FlexColumnWidth(), + children: [ + buildRow(rows.first, header: true), + for (var i = 1; i < rows.length; i++) + buildRow(rows[i], header: false), + ], + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/lib/widgets/slides/previews/text_previews.dart b/lib/widgets/slides/previews/text_previews.dart new file mode 100644 index 0000000..21ebe9d --- /dev/null +++ b/lib/widgets/slides/previews/text_previews.dart @@ -0,0 +1,478 @@ +// Part of the slide_preview library — see ../slide_preview.dart. +// Split out for navigability; all imports live in the main library file. +part of '../slide_preview.dart'; + +class _TitlePreview extends StatelessWidget { + final Slide slide; + final double w; + final String? projectPath; + final String font; + final ThemeProfile profile; + + const _TitlePreview({ + required this.slide, + required this.w, + this.projectPath, + required this.font, + required this.profile, + }); + + Widget _content(BuildContext context) { + final pad = w * 0.08; + final link = _hexColor(profile.accentColor); + return FittedBox( + fit: BoxFit.scaleDown, + alignment: Alignment.center, + child: SizedBox( + width: w, + child: Padding( + padding: EdgeInsets.all(pad), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (slide.title.isNotEmpty) + _md( + context, + slide.title, + _applyFont( + font, + TextStyle( + color: _hexColor(profile.titleTextColor), + fontSize: w * 0.055, + fontWeight: FontWeight.bold, + height: 1.2, + ), + ), + linkColor: link, + ), + if (slide.subtitle.isNotEmpty) ...[ + SizedBox(height: w * 0.02), + _md( + context, + slide.subtitle, + _applyFont( + font, + TextStyle( + color: _hexColor( + profile.titleTextColor, + ).withValues(alpha: 0.72), + fontSize: w * 0.03, + height: 1.3, + ), + ), + linkColor: link, + ), + ], + ], + ), + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + final hasBg = slide.imagePath.isNotEmpty; + + if (!hasBg) { + return Container( + color: _hexColor(profile.titleBackgroundColor), + child: SizedBox.expand(child: _content(context)), + ); + } + + return Stack( + fit: StackFit.expand, + children: [ + _zoomedImage( + context, + slide.imagePath, + projectPath, + slide.imageSize, + bgColor: _hexColor(profile.titleBackgroundColor), + ), + Container( + color: _hexColor( + profile.titleBackgroundColor, + ).withValues(alpha: 0.62), + ), + _content(context), + _captionOverlay(context, slide.imageCaption, w), + ], + ); + } +} + +class _SectionPreview extends StatelessWidget { + final Slide slide; + final double w; + final String font; + final ThemeProfile profile; + + const _SectionPreview({ + required this.slide, + required this.w, + required this.font, + required this.profile, + }); + + @override + Widget build(BuildContext context) { + final pad = w * 0.08; + return Container( + color: _hexColor(profile.sectionBackgroundColor), + child: SizedBox.expand( + child: FittedBox( + fit: BoxFit.scaleDown, + alignment: Alignment.center, + child: SizedBox( + width: w, + child: Padding( + padding: EdgeInsets.all(pad), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (slide.title.isNotEmpty) + _md( + context, + slide.title, + _applyFont( + font, + TextStyle( + color: _hexColor(profile.titleTextColor), + fontSize: w * 0.05, + fontWeight: FontWeight.bold, + height: 1.2, + ), + ), + linkColor: _hexColor(profile.accentColor), + ), + if (slide.subtitle.isNotEmpty) ...[ + SizedBox(height: w * 0.015), + _md( + context, + slide.subtitle, + _applyFont( + font, + TextStyle( + color: _hexColor( + profile.titleTextColor, + ).withValues(alpha: 0.72), + fontSize: w * 0.025, + ), + ), + linkColor: _hexColor(profile.accentColor), + ), + ], + ], + ), + ), + ), + ), + ), + ); + } +} + +class _QuotePreview extends StatelessWidget { + final Slide slide; + final double w; + final String font; + final String? projectPath; + final ThemeProfile profile; + + const _QuotePreview({ + required this.slide, + required this.w, + required this.font, + this.projectPath, + required this.profile, + }); + + @override + Widget build(BuildContext context) { + final pad = w * 0.08; + final hasBg = slide.imagePath.isNotEmpty; + final textColor = hasBg ? Colors.white : _hexColor(profile.textColor); + final authorColor = hasBg ? Colors.white70 : Colors.grey[600]!; + final accentColor = hasBg ? Colors.white : _hexColor(profile.accentColor); + + final content = FittedBox( + fit: BoxFit.scaleDown, + alignment: Alignment.center, + child: SizedBox( + width: w, + child: Padding( + padding: EdgeInsets.all(pad), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: w * 0.008, + height: w * 0.12, + color: accentColor, + margin: EdgeInsets.only(right: pad * 0.4), + ), + Expanded( + child: _md( + context, + slide.quote.isEmpty ? '' : '"${slide.quote}"', + _applyFont( + font, + TextStyle( + fontSize: w * 0.033, + fontStyle: FontStyle.italic, + color: textColor, + height: 1.4, + ), + ), + linkColor: accentColor, + ), + ), + ], + ), + if (slide.quoteAuthor.isNotEmpty) ...[ + SizedBox(height: pad * 0.6), + _md( + context, + '— ${slide.quoteAuthor}', + _applyFont( + font, + TextStyle( + fontSize: w * 0.026, + color: authorColor, + fontWeight: FontWeight.w500, + ), + ), + linkColor: accentColor, + ), + ], + ], + ), + ), + ), + ); + + if (!hasBg) { + return Container( + color: _hexColor(profile.slideBackgroundColor), + child: SizedBox.expand(child: content), + ); + } + + return Stack( + fit: StackFit.expand, + children: [ + _zoomedImage( + context, + slide.imagePath, + projectPath, + slide.imageSize, + bgColor: _hexColor(profile.slideBackgroundColor), + ), + Container(color: Colors.black.withValues(alpha: 0.52)), + content, + _captionOverlay(context, slide.imageCaption, w), + ], + ); + } +} + +class _MarkdownPreview extends StatelessWidget { + final Slide slide; + final double w; + final String font; + final ThemeProfile profile; + + const _MarkdownPreview({ + required this.slide, + required this.w, + required this.font, + required this.profile, + }); + + @override + Widget build(BuildContext context) { + final pad = w * 0.07; + final safe = slide.showLogo ? _logoSafeInsets(w, profile) : EdgeInsets.zero; + + return Container( + color: Colors.white, + child: SizedBox.expand( + child: FittedBox( + fit: BoxFit.scaleDown, + alignment: Alignment.topLeft, + child: SizedBox( + width: w, + child: Padding( + padding: EdgeInsets.fromLTRB( + pad, + pad + safe.top, + pad, + pad + safe.bottom, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: _buildBlocks(context), + ), + ), + ), + ), + ), + ); + } + + /// Parse the free Markdown into block widgets: fenced ```code``` (syntax + /// highlighted), `$$…$$` display math, and ordinary heading/bullet/text lines. + List _buildBlocks(BuildContext context) { + final link = _hexColor(profile.accentColor); + final lines = slide.customMarkdown.split('\n'); + final widgets = []; + var i = 0; + // Cap rendered blocks so a huge slide can't blow up layout (the preview is a + // thumbnail; FittedBox scales the rest down). + while (i < lines.length && widgets.length < 24) { + final line = lines[i]; + + // Fenced code block: ``` or ```language … ``` + final fence = RegExp(r'^\s*```(.*)$').firstMatch(line); + if (fence != null) { + final language = fence.group(1)!.trim(); + final code = []; + i++; + while (i < lines.length && !RegExp(r'^\s*```\s*$').hasMatch(lines[i])) { + code.add(lines[i]); + i++; + } + if (i < lines.length) i++; // consume the closing fence + widgets.add(_codeBlock(code.join('\n'), language)); + continue; + } + + // Display math fenced by lines containing only `$$`. + if (line.trim() == r'$$') { + final tex = []; + i++; + while (i < lines.length && lines[i].trim() != r'$$') { + tex.add(lines[i]); + i++; + } + if (i < lines.length) i++; // consume the closing $$ + widgets.add(_mathBlock(tex.join('\n'))); + continue; + } + // Single-line display math: $$ … $$ + final oneLine = RegExp(r'^\s*\$\$(.+)\$\$\s*$').firstMatch(line); + if (oneLine != null) { + widgets.add(_mathBlock(oneLine.group(1)!.trim())); + i++; + continue; + } + + widgets.add(_textLine(context, line, link)); + i++; + } + return widgets; + } + + Widget _textLine(BuildContext context, String line, Color link) { + if (line.startsWith('# ')) { + return _md( + context, + line.substring(2), + _applyFont( + font, + TextStyle( + fontSize: w * 0.04, + fontWeight: FontWeight.bold, + color: AppTheme.navy, + ), + ), + linkColor: link, + ); + } else if (line.startsWith('## ')) { + return _md( + context, + line.substring(3), + _applyFont( + font, + TextStyle(fontSize: w * 0.03, fontWeight: FontWeight.w600), + ), + linkColor: link, + ); + } else if (line.startsWith('- ')) { + return _md( + context, + '• ${line.substring(2)}', + _applyFont(font, TextStyle(fontSize: w * 0.024)), + linkColor: link, + ); + } else if (line.isEmpty) { + return SizedBox(height: w * 0.01); + } + return _md( + context, + line, + _applyFont(font, TextStyle(fontSize: w * 0.024)), + linkColor: link, + ); + } + + Widget _codeBlock(String code, String language) { + _ensureHighlightLanguages(); + final mono = TextStyle( + fontFamily: 'monospace', + fontSize: w * 0.02, + height: 1.3, + color: const Color(0xFF24292E), + ); + // HighlightView throws on an unregistered language, so only use it for ones + // we actually know; otherwise fall back to plain monospace. + final known = language.isNotEmpty && allLanguages.containsKey(language); + final Widget content = known + ? HighlightView( + code, + language: language, + theme: githubTheme, + padding: EdgeInsets.zero, + textStyle: mono, + ) + : Text(code, style: mono); + return Container( + width: double.infinity, + margin: EdgeInsets.symmetric(vertical: w * 0.008), + padding: EdgeInsets.all(w * 0.018), + decoration: BoxDecoration( + color: const Color(0xFFF6F8FA), + borderRadius: BorderRadius.circular(w * 0.008), + border: Border.all(color: const Color(0xFFE1E4E8)), + ), + child: content, + ); + } + + Widget _mathBlock(String tex) { + return Padding( + padding: EdgeInsets.symmetric(vertical: w * 0.012), + child: Math.tex( + tex, + textStyle: _applyFont(font, TextStyle(fontSize: w * 0.032)), + onErrorFallback: (err) => Text( + '\$\$$tex\$\$', + style: TextStyle( + fontFamily: 'monospace', + fontSize: w * 0.022, + color: Colors.red, + ), + ), + ), + ); + } +} diff --git a/lib/widgets/slides/slide_preview.dart b/lib/widgets/slides/slide_preview.dart index 8da485d..16edc1a 100644 --- a/lib/widgets/slides/slide_preview.dart +++ b/lib/widgets/slides/slide_preview.dart @@ -16,8 +16,20 @@ import '../../models/deck.dart'; import '../../models/settings.dart'; import '../../models/slide.dart'; import '../../theme/app_theme.dart'; +import '../../utils/log.dart'; import 'inline_markdown.dart'; +// Slide preview widgets, split into part files by slide type for +// navigability. These parts share this library's imports and private scope. +part 'previews/text_previews.dart'; +part 'previews/bullets_previews.dart'; +part 'previews/checklist_previews.dart'; +part 'previews/table_preview.dart'; +part 'previews/media_previews.dart'; +part 'previews/code_preview.dart'; +part 'previews/chart_preview.dart'; +part 'previews/overlays.dart'; + /// Returns a TextStyle with the correct font. 'EB Garamond' is bundled with the /// app (see pubspec.yaml); all other fonts resolve to system families. TextStyle _applyFont(String font, TextStyle base) { @@ -370,4076 +382,6 @@ class SlidePreviewWidget extends StatelessWidget { } } -class _AudioPlayback extends StatefulWidget { - final String audioPath; - final String? projectPath; - final bool autoplay; - final double w; - final VoidCallback? onComplete; - - const _AudioPlayback({ - required this.audioPath, - required this.projectPath, - required this.autoplay, - required this.w, - this.onComplete, - }); - - @override - State<_AudioPlayback> createState() => _AudioPlaybackState(); -} - -class _AudioPlaybackState extends State<_AudioPlayback> { - VideoPlayerController? _controller; - bool _completed = false; - - @override - void initState() { - super.initState(); - _init(); - } - - @override - void didUpdateWidget(_AudioPlayback oldWidget) { - super.didUpdateWidget(oldWidget); - if (oldWidget.audioPath != widget.audioPath || - oldWidget.autoplay != widget.autoplay) { - _init(); - } - } - - Future _init() async { - _controller?.removeListener(_onTick); - await _controller?.dispose(); - _completed = false; - final path = _resolvePath(widget.audioPath, widget.projectPath); - if (path == null) return; - final controller = VideoPlayerController.file(File(path)); - _controller = controller; - try { - await controller.initialize(); - controller.addListener(_onTick); - if (widget.autoplay) await controller.play(); - } catch (_) {} - if (mounted) setState(() {}); - } - - /// Detecteer het einde van de audio en meld dat één keer (voor auto-advance). - void _onTick() { - final c = _controller; - if (c == null || !c.value.isInitialized || _completed) return; - final pos = c.value.position; - final dur = c.value.duration; - if (dur > Duration.zero && - pos.inMilliseconds >= dur.inMilliseconds - 200 && - !c.value.isPlaying) { - _completed = true; - widget.onComplete?.call(); - } - } - - @override - void dispose() { - _controller?.removeListener(_onTick); - _controller?.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final controller = _controller; - return Positioned( - right: widget.w * 0.035, - bottom: widget.w * 0.035, - child: IconButton( - tooltip: 'Audio', - onPressed: controller == null || !controller.value.isInitialized - ? null - : () { - setState(() { - controller.value.isPlaying - ? controller.pause() - : controller.play(); - }); - }, - icon: Icon( - controller?.value.isPlaying == true - ? Icons.volume_up - : Icons.volume_up_outlined, - ), - iconSize: widget.w * 0.032, - ), - ); - } -} - -// ── Individual slide-type renderers ────────────────────────────────────────── - -class _TitlePreview extends StatelessWidget { - final Slide slide; - final double w; - final String? projectPath; - final String font; - final ThemeProfile profile; - - const _TitlePreview({ - required this.slide, - required this.w, - this.projectPath, - required this.font, - required this.profile, - }); - - Widget _content(BuildContext context) { - final pad = w * 0.08; - final link = _hexColor(profile.accentColor); - return FittedBox( - fit: BoxFit.scaleDown, - alignment: Alignment.center, - child: SizedBox( - width: w, - child: Padding( - padding: EdgeInsets.all(pad), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (slide.title.isNotEmpty) - _md( - context, - slide.title, - _applyFont( - font, - TextStyle( - color: _hexColor(profile.titleTextColor), - fontSize: w * 0.055, - fontWeight: FontWeight.bold, - height: 1.2, - ), - ), - linkColor: link, - ), - if (slide.subtitle.isNotEmpty) ...[ - SizedBox(height: w * 0.02), - _md( - context, - slide.subtitle, - _applyFont( - font, - TextStyle( - color: _hexColor( - profile.titleTextColor, - ).withValues(alpha: 0.72), - fontSize: w * 0.03, - height: 1.3, - ), - ), - linkColor: link, - ), - ], - ], - ), - ), - ), - ); - } - - @override - Widget build(BuildContext context) { - final hasBg = slide.imagePath.isNotEmpty; - - if (!hasBg) { - return Container( - color: _hexColor(profile.titleBackgroundColor), - child: SizedBox.expand(child: _content(context)), - ); - } - - return Stack( - fit: StackFit.expand, - children: [ - _zoomedImage( - context, - slide.imagePath, - projectPath, - slide.imageSize, - bgColor: _hexColor(profile.titleBackgroundColor), - ), - Container( - color: _hexColor( - profile.titleBackgroundColor, - ).withValues(alpha: 0.62), - ), - _content(context), - _captionOverlay(context, slide.imageCaption, w), - ], - ); - } -} - -class _SectionPreview extends StatelessWidget { - final Slide slide; - final double w; - final String font; - final ThemeProfile profile; - - const _SectionPreview({ - required this.slide, - required this.w, - required this.font, - required this.profile, - }); - - @override - Widget build(BuildContext context) { - final pad = w * 0.08; - return Container( - color: _hexColor(profile.sectionBackgroundColor), - child: SizedBox.expand( - child: FittedBox( - fit: BoxFit.scaleDown, - alignment: Alignment.center, - child: SizedBox( - width: w, - child: Padding( - padding: EdgeInsets.all(pad), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (slide.title.isNotEmpty) - _md( - context, - slide.title, - _applyFont( - font, - TextStyle( - color: _hexColor(profile.titleTextColor), - fontSize: w * 0.05, - fontWeight: FontWeight.bold, - height: 1.2, - ), - ), - linkColor: _hexColor(profile.accentColor), - ), - if (slide.subtitle.isNotEmpty) ...[ - SizedBox(height: w * 0.015), - _md( - context, - slide.subtitle, - _applyFont( - font, - TextStyle( - color: _hexColor( - profile.titleTextColor, - ).withValues(alpha: 0.72), - fontSize: w * 0.025, - ), - ), - linkColor: _hexColor(profile.accentColor), - ), - ], - ], - ), - ), - ), - ), - ), - ); - } -} - -class _BulletsPreview extends StatelessWidget { - final Slide slide; - final double w; - final String font; - final ThemeProfile profile; - - const _BulletsPreview({ - required this.slide, - required this.w, - required this.font, - required this.profile, - }); - - @override - Widget build(BuildContext context) { - final pad = w * 0.07; - // Slightly tighter top/bottom margin than the side margin so short - // checklists can grow into more of the slide height instead of leaving a - // wide empty band below the text. - final vPad = w * 0.05; - final safe = slide.showLogo ? _logoSafeInsets(w, profile) : EdgeInsets.zero; - final titleSize = w * 0.042; - final subtitleSize = w * 0.030; - final bulletSize = w * 0.026; - final spacing = pad * 0.5; - final bulletGap = w * 0.006; - final bullets = slide.bullets - .where((b) => b.trimLeft().isNotEmpty) - .toList(); - final hasTitle = slide.title.isNotEmpty; - final subtitle = slide.subtitle; - final hasSubtitle = subtitle.isNotEmpty; - final showProgress = - slide.listStyle == ListStyle.checklist && - slide.showChecklistProgress && - bullets.isNotEmpty; - - final slideHeight = w * 9 / 16; - final availW = (w - pad * 2).clamp(w * 0.12, w); - // The progress chart only needs a modest, fixed slot; give all remaining - // width to the bullets so the text can grow as large (and readable) as - // possible, especially on slides with many checklist items. - final progressGap = w * 0.025; - final progressW = w * 0.34; - final textAvailW = showProgress - ? (availW - progressGap - progressW).clamp(w * 0.12, availW) - : availW; - final availH = slideHeight - (vPad + safe.top) - (vPad + safe.bottom); - // Grow (or, when needed, shrink) the text so it uses the full vertical - // space instead of leaving a large empty area below a few short bullets. - final scale = _bulletsFitScale( - availW: textAvailW, - availH: availH, - hasTitle: hasTitle, - title: slide.title, - bullets: bullets, - titleSize: titleSize, - bulletSize: bulletSize, - spacing: spacing, - bulletGap: bulletGap, - font: font, - subtitle: subtitle, - subtitleSize: subtitleSize, - maxScale: _bulletScaleCap(w, bulletSize, _kSplitBulletsMaxScale), - listStyle: slide.listStyle, - ); - - return Container( - color: _hexColor(profile.slideBackgroundColor), - child: SizedBox.expand( - child: FittedBox( - fit: BoxFit.scaleDown, - alignment: Alignment.topLeft, - child: SizedBox( - width: w, - child: Padding( - padding: EdgeInsets.fromLTRB( - pad, - vPad + safe.top, - pad, - vPad + safe.bottom, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - if (hasTitle) - _md( - context, - slide.title, - _applyFont( - font, - TextStyle( - fontSize: titleSize * scale, - fontWeight: FontWeight.bold, - color: _hexColor(profile.textColor), - ), - ), - linkColor: _hexColor(profile.accentColor), - ), - if (hasSubtitle) ...[ - SizedBox(height: spacing * scale * 0.4), - _md( - context, - subtitle, - _applyFont( - font, - TextStyle( - fontSize: subtitleSize * scale, - fontWeight: FontWeight.w600, - color: _hexColor(profile.accentColor), - ), - ), - linkColor: _hexColor(profile.accentColor), - ), - ], - if ((hasTitle || hasSubtitle) && bullets.isNotEmpty) - SizedBox(height: spacing * scale), - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: _BulletListColumn( - bullets: bullets, - listStyle: slide.listStyle, - font: font, - profile: profile, - bulletSize: bulletSize, - bulletGap: bulletGap, - scale: scale, - column: 0, - ), - ), - if (showProgress) ...[ - SizedBox(width: progressGap), - SizedBox( - width: progressW, - child: Center( - child: _ChecklistProgress( - bullets: bullets, - w: w, - font: font, - profile: profile, - ), - ), - ), - ], - ], - ), - ], - ), - ), - ), - ), - ), - ); - } -} - -class _TablePreview extends StatelessWidget { - final Slide slide; - final double w; - final String font; - final ThemeProfile profile; - - const _TablePreview({ - required this.slide, - required this.w, - required this.font, - required this.profile, - }); - - @override - Widget build(BuildContext context) { - final pad = w * 0.06; - final safe = slide.showLogo - ? _splitTextLogoSafeInsets(w, profile) - : EdgeInsets.zero; - final titleSize = w * 0.038; - final rows = slide.tableRows.where((r) => r.isNotEmpty).toList(); - final colCount = rows.fold(0, (m, r) => r.length > m ? r.length : m); - - // Scale cell text down as the table grows so it keeps fitting nicely. - final density = (rows.length + colCount).clamp(2, 24); - final cellSize = (w * 0.025 * (10 / (density + 6))).clamp( - w * 0.010, - w * 0.021, - ); - - final accent = _hexColor(profile.accentColor); - final textColor = _hexColor(profile.tableTextColor); - final headerTextColor = _hexColor(profile.tableHeaderTextColor); - final borderColor = accent.withValues(alpha: 0.35); - - Widget cell(String value, {required bool header}) { - return Padding( - padding: EdgeInsets.symmetric( - horizontal: cellSize * 0.55, - vertical: cellSize * 0.36, - ), - child: _md( - context, - value, - _applyFont( - font, - TextStyle( - fontSize: cellSize, - color: header ? headerTextColor : textColor, - fontWeight: header ? FontWeight.bold : FontWeight.normal, - ), - ), - linkColor: header ? headerTextColor : accent, - ), - ); - } - - TableRow buildRow(List row, {required bool header}) { - return TableRow( - decoration: BoxDecoration(color: header ? accent : null), - children: List.generate(colCount, (c) { - final value = c < row.length ? row[c] : ''; - return TableCell( - verticalAlignment: TableCellVerticalAlignment.middle, - child: cell(value, header: header), - ); - }), - ); - } - - return Container( - color: _hexColor(profile.slideBackgroundColor), - child: FittedBox( - fit: BoxFit.scaleDown, - alignment: Alignment.topLeft, - child: SizedBox( - width: w, - child: Padding( - padding: EdgeInsets.fromLTRB( - pad, - pad + safe.top, - pad, - pad + safe.bottom, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - if (slide.title.isNotEmpty) ...[ - _md( - context, - slide.title, - _applyFont( - font, - TextStyle( - fontSize: titleSize, - fontWeight: FontWeight.bold, - color: _hexColor(profile.textColor), - ), - ), - linkColor: _hexColor(profile.accentColor), - ), - SizedBox(height: pad * 0.35), - ], - if (rows.isNotEmpty && colCount > 0) - Table( - border: TableBorder.all( - color: borderColor, - width: w * 0.0012, - ), - defaultColumnWidth: const FlexColumnWidth(), - children: [ - buildRow(rows.first, header: true), - for (var i = 1; i < rows.length; i++) - buildRow(rows[i], header: false), - ], - ), - ], - ), - ), - ), - ), - ); - } -} - -class _TwoBulletsPreview extends StatelessWidget { - final Slide slide; - final double w; - final String font; - final ThemeProfile profile; - - const _TwoBulletsPreview({ - required this.slide, - required this.w, - required this.font, - required this.profile, - }); - - /// One bullet column with an optional heading above it. When any column has a - /// heading, an equal-height slot is reserved in both so the bullet lists line - /// up. - Widget _bulletColumn( - BuildContext context, { - required String title, - required List bullets, - required double columnW, - required double headingSize, - required double headingSlotH, - required double headingGap, - required double bulletSize, - required double bulletGap, - required double scale, - required int column, - }) { - return SizedBox( - width: columnW, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - if (headingSlotH > 0) ...[ - SizedBox( - width: double.infinity, - height: headingSlotH, - child: title.isEmpty - ? null - : _md( - context, - title, - _applyFont( - font, - TextStyle( - fontSize: headingSize, - fontWeight: FontWeight.bold, - color: _hexColor(profile.accentColor), - ), - ), - linkColor: _hexColor(profile.accentColor), - ), - ), - SizedBox(height: headingGap), - ], - _BulletListColumn( - bullets: bullets, - listStyle: slide.listStyle, - font: font, - profile: profile, - bulletSize: bulletSize, - bulletGap: bulletGap, - scale: scale, - column: column, - ), - ], - ), - ); - } - - @override - Widget build(BuildContext context) { - final pad = w * 0.065; - // Tighter top/bottom margin than the side margin so dense columns (e.g. a - // 19-item list) can use more of the slide height and stay readable. - final vPad = w * 0.045; - final safe = slide.showLogo ? _logoSafeInsets(w, profile) : EdgeInsets.zero; - final leftBullets = slide.bullets - .where((b) => b.trimLeft().isNotEmpty) - .toList(); - final rightBullets = slide.bullets2 - .where((b) => b.trimLeft().isNotEmpty) - .toList(); - final hasTitle = slide.title.isNotEmpty; - - // On dense slides (a long column drives the shared text size down) spend - // less of the height on the title, headings and inter-item gaps so the - // list items themselves can render larger and stay readable. - final dense = math.max(leftBullets.length, rightBullets.length) > 12; - final titleSize = w * (dense ? 0.034 : 0.04); - final bulletSize = w * 0.024; - final spacing = pad * (dense ? 0.28 : 0.38); - final bulletGap = w * (dense ? 0.0036 : 0.0055); - final columnGap = w * 0.055; - - final col1Title = slide.columnTitle1.trim(); - final col2Title = slide.columnTitle2.trim(); - final hasColumnTitles = col1Title.isNotEmpty || col2Title.isNotEmpty; - final headingSize = w * (dense ? 0.023 : 0.03); - final headingGap = w * (dense ? 0.007 : 0.012); - - final slideHeight = w * 9 / 16; - final contentW = (w - pad * 2).clamp(w * 0.12, w); - final columnW = ((contentW - columnGap) / 2).clamp(w * 0.12, w); - var availH = slideHeight - (vPad + safe.top) - (vPad + safe.bottom); - if (hasTitle) { - availH -= _measureTextHeight( - slide.title, - titleSize, - contentW, - bold: true, - fontFamily: font, - ); - availH -= spacing; - } - // Reserve room for the (optional) column headings so the bullets still fit. - double headingHeight(String t) => t.isEmpty - ? 0 - : _measureTextHeight( - t, - headingSize, - columnW, - bold: true, - fontFamily: font, - ); - final maxHeadingH = math.max( - headingHeight(col1Title), - headingHeight(col2Title), - ); - if (hasColumnTitles) availH -= maxHeadingH + headingGap; - final leftScale = _bulletsFitScale( - availW: columnW, - availH: availH, - hasTitle: false, - title: '', - bullets: leftBullets, - titleSize: titleSize, - bulletSize: bulletSize, - spacing: spacing, - bulletGap: bulletGap, - font: font, - maxScale: _bulletScaleCap(w, bulletSize, _kBulletsMaxScale), - listStyle: slide.listStyle, - ); - final rightScale = _bulletsFitScale( - availW: columnW, - availH: availH, - hasTitle: false, - title: '', - bullets: rightBullets, - titleSize: titleSize, - bulletSize: bulletSize, - spacing: spacing, - bulletGap: bulletGap, - font: font, - maxScale: _bulletScaleCap(w, bulletSize, _kBulletsMaxScale), - listStyle: slide.listStyle, - ); - // Treat both columns as one composition: the busiest column determines - // the shared text size, so left and right never look typographically - // unrelated. - final columnScale = math.min(leftScale, rightScale); - - return Container( - color: _hexColor(profile.slideBackgroundColor), - child: SizedBox.expand( - child: Padding( - padding: EdgeInsets.fromLTRB( - pad, - vPad + safe.top, - pad, - vPad + safe.bottom, - ), - child: FittedBox( - fit: BoxFit.scaleDown, - alignment: Alignment.topLeft, - child: SizedBox( - width: contentW, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - if (hasTitle) - _md( - context, - slide.title, - _applyFont( - font, - TextStyle( - fontSize: titleSize, - fontWeight: FontWeight.bold, - color: _hexColor(profile.textColor), - ), - ), - linkColor: _hexColor(profile.accentColor), - ), - if (hasTitle) SizedBox(height: spacing), - if (slide.listStyle == ListStyle.checklist && - slide.showChecklistProgress && - (leftBullets.isNotEmpty || rightBullets.isNotEmpty)) ...[ - Align( - alignment: Alignment.center, - child: SizedBox( - width: contentW * 0.5, - child: _ChecklistProgress( - bullets: [...leftBullets, ...rightBullets], - w: w, - font: font, - profile: profile, - ), - ), - ), - SizedBox(height: spacing), - ], - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _bulletColumn( - context, - title: col1Title, - bullets: leftBullets, - columnW: columnW, - headingSize: headingSize, - headingSlotH: hasColumnTitles ? maxHeadingH : 0, - headingGap: headingGap, - bulletSize: bulletSize, - bulletGap: bulletGap, - scale: columnScale, - column: 0, - ), - SizedBox(width: columnGap), - _bulletColumn( - context, - title: col2Title, - bullets: rightBullets, - columnW: columnW, - headingSize: headingSize, - headingSlotH: hasColumnTitles ? maxHeadingH : 0, - headingGap: headingGap, - bulletSize: bulletSize, - bulletGap: bulletGap, - scale: columnScale, - column: 1, - ), - ], - ), - ], - ), - ), - ), - ), - ), - ); - } -} - -class _BulletsImagePreview extends StatelessWidget { - final Slide slide; - final double w; - final String? projectPath; - final String font; - final ThemeProfile profile; - - const _BulletsImagePreview({ - required this.slide, - required this.w, - this.projectPath, - required this.font, - required this.profile, - }); - - @override - Widget build(BuildContext context) { - final leftPad = w * 0.038; - final verticalPad = w * 0.042; - // Keep the gap between the text column and the image equal to the slide's - // left margin so the layout stays symmetric. - final gap = leftPad; - final safe = slide.showLogo - ? _splitTextLogoSafeInsets(w, profile) - : EdgeInsets.zero; - final imgFraction = (slide.imageSize > 0 ? slide.imageSize / 100.0 : 0.40) - .clamp(0.1, 0.70); - final imgWidth = w * imgFraction; - final bulletSize = w * 0.031; - final titleSize = w * 0.042; - final spacing = verticalPad * 0.32; - final bulletGap = w * 0.005; - final bullets = slide.bullets - .where((b) => b.trimLeft().isNotEmpty) - .toList(); - final hasTitle = slide.title.isNotEmpty; - - // The slide is always rendered 16:9, so the available area for the text - // column is fully determined by the width. Computing it directly (instead - // of via a LayoutBuilder) keeps the widget tree identical to the image - // side and avoids any layout-timing surprises. - final slideHeight = w * 9 / 16; - final availW = (w - imgWidth - gap - leftPad).clamp(w * 0.12, w); - final availH = - slideHeight - (verticalPad + safe.top) - (verticalPad + safe.bottom); - // Pick the largest font scale (capped at the design size) whose content - // still fits the available height at the full column width. This keeps the - // text as large as possible and lets it span the full width toward the - // image, instead of uniformly shrinking and leaving a wide gap. - final scale = _bulletsFitScale( - availW: availW, - availH: availH, - hasTitle: hasTitle, - title: slide.title, - bullets: bullets, - titleSize: titleSize, - bulletSize: bulletSize, - spacing: spacing, - bulletGap: bulletGap, - font: font, - maxScale: _bulletScaleCap(w, bulletSize, _kBulletsMaxScale), - listStyle: slide.listStyle, - ); - - return Container( - color: _hexColor(profile.slideBackgroundColor), - child: Stack( - children: [ - Positioned( - top: 0, - right: 0, - bottom: 0, - width: imgWidth, - child: Stack( - fit: StackFit.expand, - children: [ - _resolvedImage(context, slide.imagePath, projectPath), - _captionOverlay(context, slide.imageCaption, w), - ], - ), - ), - Positioned( - top: 0, - left: 0, - right: imgWidth + gap, - bottom: 0, - child: Padding( - padding: EdgeInsets.fromLTRB( - leftPad, - verticalPad + safe.top, - 0, - verticalPad + safe.bottom, - ), - // FittedBox stays as a safety net for measurement rounding; with - // an accurate scale it renders at scale 1 (full width). - child: FittedBox( - fit: BoxFit.scaleDown, - alignment: Alignment.topLeft, - child: SizedBox( - width: availW, - child: _contentColumn( - context: context, - scale: scale, - bullets: bullets, - hasTitle: hasTitle, - titleSize: titleSize, - bulletSize: bulletSize, - spacing: spacing, - bulletGap: bulletGap, - ), - ), - ), - ), - ), - ], - ), - ); - } - - Widget _contentColumn({ - required BuildContext context, - required double scale, - required List bullets, - required bool hasTitle, - required double titleSize, - required double bulletSize, - required double spacing, - required double bulletGap, - }) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - if (hasTitle) - _md( - context, - slide.title, - _applyFont( - font, - TextStyle( - fontSize: titleSize * scale, - fontWeight: FontWeight.bold, - color: _hexColor(profile.textColor), - ), - ), - linkColor: _hexColor(profile.accentColor), - ), - if (hasTitle && bullets.isNotEmpty) SizedBox(height: spacing * scale), - if (slide.listStyle == ListStyle.checklist && - slide.showChecklistProgress && - bullets.isNotEmpty) ...[ - _ChecklistProgress( - bullets: bullets, - w: w, - font: font, - profile: profile, - ), - SizedBox(height: spacing * scale), - ], - ...bullets.asMap().entries.map((entry) { - final b = entry.value; - int level = 0; - while (level < b.length && b[level] == '\t') { - level++; - } - final text = slide.listStyle == ListStyle.checklist - ? checklistItemText(b) - : b.substring(level); - final checked = - slide.listStyle == ListStyle.checklist && checklistItemChecked(b); - final fontSize = bulletSize * _bulletLevelScale(level) * scale; - return _ChecklistBulletRow( - bullets: bullets, - itemIndex: entry.key, - column: 0, - listStyle: slide.listStyle, - checked: checked, - text: text, - level: level, - fontSize: fontSize, - bulletSize: bulletSize, - bulletGap: bulletGap, - scale: scale, - font: font, - profile: profile, - ); - }), - ], - ); - } -} - -class _ChecklistProgress extends StatelessWidget { - final List bullets; - final double w; - final String font; - final ThemeProfile profile; - - const _ChecklistProgress({ - required this.bullets, - required this.w, - required this.font, - required this.profile, - }); - - @override - Widget build(BuildContext context) { - final items = bullets - .where((bullet) => checklistItemText(bullet).trim().isNotEmpty) - .toList(); - final checked = items.where(checklistItemChecked).length; - final total = items.length; - final checkedPercent = total == 0 ? 0 : ((checked / total) * 100).round(); - final openPercent = total == 0 ? 0 : 100 - checkedPercent; - final textColor = _hexColor(profile.textColor); - final checkedColor = _hexColor(profile.checklistCheckedColor); - final openColor = _hexColor(profile.checklistUncheckedColor); - final labelStyle = _applyFont( - font, - TextStyle( - fontSize: w * 0.0125, - height: 1.2, - color: textColor, - fontWeight: FontWeight.w600, - ), - ); - - final interaction = _ChecklistInteractionScope.maybeOf(context); - - return LayoutBuilder( - builder: (context, constraints) { - // Grow the pie to fill the width it is handed instead of staying at a - // fixed, tiny size. Every caller gives this widget a bounded column - // width, so the chart now scales with the space that is actually - // available next to (or above) the bullets. - final maxW = constraints.maxWidth.isFinite - ? constraints.maxWidth - : w * 0.4; - // Cap the pie so it stays a balanced companion to the bullet column - // rather than dominating it: a smaller chart keeps the visual split - // closer to 50/50 and, crucially, never forces the surrounding text to - // shrink to fit the chart's height when a slide has many bullets. - final diameter = maxW.clamp(w * 0.22, w * 0.30).toDouble(); - final baseRadius = diameter * 0.44; - final hoverRadius = diameter * 0.48; - final pieTitleStyle = _applyFont( - font, - TextStyle( - fontSize: diameter * 0.085, - height: 1.1, - fontWeight: FontWeight.bold, - color: textColor, - ), - ); - - Widget pie(bool? hovered) => PieChart( - key: const ValueKey('checklist-progress-pie'), - PieChartData( - sectionsSpace: w * 0.002, - centerSpaceRadius: 0, - startDegreeOffset: -90, - sections: [ - if (checkedPercent > 0) - PieChartSectionData( - value: checkedPercent.toDouble(), - color: checkedColor, - radius: hovered == true ? hoverRadius : baseRadius, - title: '$checkedPercent%', - titleStyle: pieTitleStyle.copyWith(color: Colors.white), - ), - if (openPercent > 0) - PieChartSectionData( - value: openPercent.toDouble(), - color: openColor, - radius: hovered == false ? hoverRadius : baseRadius, - title: '$openPercent%', - titleStyle: pieTitleStyle, - ), - ], - pieTouchData: PieTouchData( - enabled: interaction?.enabled == true, - touchCallback: (event, response) { - if (interaction?.enabled != true) return; - final index = event.isInterestedForInteractions - ? response?.touchedSection?.touchedSectionIndex - : null; - if (index == null) { - interaction!.hovered.value = null; - } else if (checkedPercent == 0) { - interaction!.hovered.value = false; - } else { - interaction!.hovered.value = index == 0; - } - }, - ), - ), - duration: Duration.zero, - ); - - return Semantics( - label: - '${context.l10n.d('Afgevinkt')} $checkedPercent%, ' - '${context.l10n.d('Niet afgevinkt')} $openPercent%', - child: Column( - crossAxisAlignment: CrossAxisAlignment.center, - mainAxisSize: MainAxisSize.min, - children: [ - SizedBox( - width: diameter, - height: diameter, - child: interaction == null - ? pie(null) - : ValueListenableBuilder( - valueListenable: interaction.hovered, - builder: (_, hovered, _) => pie(hovered), - ), - ), - SizedBox(height: w * 0.008), - MouseRegion( - key: const ValueKey('checklist-progress-checked'), - onEnter: interaction?.enabled != true - ? null - : (_) => interaction!.hovered.value = true, - onExit: interaction?.enabled != true - ? null - : (_) => interaction!.hovered.value = null, - child: Text( - '${context.l10n.d('Afgevinkt')} $checkedPercent%', - style: labelStyle, - ), - ), - MouseRegion( - key: const ValueKey('checklist-progress-unchecked'), - onEnter: interaction?.enabled != true - ? null - : (_) => interaction!.hovered.value = false, - onExit: interaction?.enabled != true - ? null - : (_) => interaction!.hovered.value = null, - child: Text( - '${context.l10n.d('Niet afgevinkt')} $openPercent%', - style: labelStyle.copyWith( - color: textColor.withValues(alpha: 0.7), - ), - ), - ), - ], - ), - ); - }, - ); - } -} - -class _BulletListColumn extends StatelessWidget { - final List bullets; - final ListStyle listStyle; - final String font; - final ThemeProfile profile; - final double bulletSize; - final double bulletGap; - final double scale; - final int column; - - const _BulletListColumn({ - required this.bullets, - required this.listStyle, - required this.font, - required this.profile, - required this.bulletSize, - required this.bulletGap, - required this.scale, - this.column = 0, - }); - - @override - Widget build(BuildContext context) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - ...bullets.asMap().entries.map((entry) { - final b = entry.value; - int level = 0; - while (level < b.length && b[level] == '\t') { - level++; - } - final text = listStyle == ListStyle.checklist - ? checklistItemText(b) - : b.substring(level); - final checked = - listStyle == ListStyle.checklist && checklistItemChecked(b); - final fontSize = bulletSize * _bulletLevelScale(level) * scale; - return _ChecklistBulletRow( - bullets: bullets, - itemIndex: entry.key, - column: column, - listStyle: listStyle, - checked: checked, - text: text, - level: level, - fontSize: fontSize, - bulletSize: bulletSize, - bulletGap: bulletGap, - scale: scale, - font: font, - profile: profile, - ); - }), - ], - ); - } -} - -class _ChecklistBulletRow extends StatelessWidget { - final List bullets; - final int itemIndex; - final int column; - final ListStyle listStyle; - final bool checked; - final String text; - final int level; - final double fontSize; - final double bulletSize; - final double bulletGap; - final double scale; - final String font; - final ThemeProfile profile; - - const _ChecklistBulletRow({ - required this.bullets, - required this.itemIndex, - required this.column, - required this.listStyle, - required this.checked, - required this.text, - required this.level, - required this.fontSize, - required this.bulletSize, - required this.bulletGap, - required this.scale, - required this.font, - required this.profile, - }); - - @override - Widget build(BuildContext context) { - final interaction = _ChecklistInteractionScope.maybeOf(context); - Widget row(bool highlighted) => AnimatedContainer( - key: ValueKey('checklist-preview-item-$column-$itemIndex'), - duration: const Duration(milliseconds: 140), - padding: EdgeInsets.symmetric(horizontal: highlighted ? wScale(6) : 0), - decoration: BoxDecoration( - color: highlighted - ? _hexColor(profile.accentColor).withValues(alpha: 0.16) - : Colors.transparent, - borderRadius: BorderRadius.circular(wScale(5)), - ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - GestureDetector( - key: ValueKey('checklist-preview-toggle-$column-$itemIndex'), - behavior: HitTestBehavior.opaque, - onTap: - listStyle == ListStyle.checklist && interaction?.enabled == true - ? () => interaction!.onToggle?.call(column, itemIndex) - : null, - child: MouseRegion( - cursor: - listStyle == ListStyle.checklist && - interaction?.enabled == true - ? SystemMouseCursors.click - : MouseCursor.defer, - child: Text( - '${_listMarker(bullets, itemIndex, listStyle)} ', - style: TextStyle( - fontSize: fontSize, - color: _hexColor(profile.accentColor), - fontWeight: FontWeight.bold, - ), - ), - ), - ), - Expanded( - child: _md( - context, - text, - _applyFont( - font, - TextStyle( - fontSize: fontSize, - height: _kBulletLineHeight, - color: _hexColor(profile.textColor), - decoration: checked && profile.checklistStrikeThrough - ? TextDecoration.lineThrough - : null, - decorationColor: _hexColor(profile.textColor), - ), - ), - linkColor: _hexColor(profile.accentColor), - ), - ), - ], - ), - ); - - final padded = Padding( - padding: EdgeInsets.only( - left: level * bulletSize * 1.05 * scale, - top: bulletGap * scale, - bottom: bulletGap * scale, - ), - child: interaction == null || listStyle != ListStyle.checklist - ? row(false) - : ValueListenableBuilder( - valueListenable: interaction.hovered, - builder: (_, hovered, _) => row(hovered == checked), - ), - ); - return padded; - } - - double wScale(double value) => value * scale; -} - -class _ChecklistInteractionHost extends StatefulWidget { - final bool enabled; - final void Function(int column, int itemIndex)? onToggle; - final Widget child; - - const _ChecklistInteractionHost({ - required this.enabled, - required this.onToggle, - required this.child, - }); - - @override - State<_ChecklistInteractionHost> createState() => - _ChecklistInteractionHostState(); -} - -class _ChecklistInteractionHostState extends State<_ChecklistInteractionHost> { - final ValueNotifier hovered = ValueNotifier(null); - - @override - void dispose() { - hovered.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return _ChecklistInteractionScope( - enabled: widget.enabled, - hovered: hovered, - onToggle: widget.onToggle, - child: widget.child, - ); - } -} - -class _ChecklistInteractionScope extends InheritedWidget { - final bool enabled; - final ValueNotifier hovered; - final void Function(int column, int itemIndex)? onToggle; - - const _ChecklistInteractionScope({ - required this.enabled, - required this.hovered, - required this.onToggle, - required super.child, - }); - - static _ChecklistInteractionScope? maybeOf(BuildContext context) => - context.dependOnInheritedWidgetOfExactType<_ChecklistInteractionScope>(); - - @override - bool updateShouldNotify(_ChecklistInteractionScope oldWidget) => - enabled != oldWidget.enabled || onToggle != oldWidget.onToggle; -} - -/// Upper bound for growing bullet text to fill otherwise empty vertical space. -const double _kBulletsMaxScale = 3.2; - -/// Split slides have a much narrower column, so short bullet lists can stay -/// visually timid unless they are allowed to grow a little further. -const double _kSplitBulletsMaxScale = 4.35; - -/// Hard ceiling for the *rendered* bullet text size when auto-growing, as a -/// fraction of the slide width: ≈32pt on a standard 16:9 deck (PowerPoint's -/// 960pt-wide canvas). Presentation-design guidance consistently puts body -/// text at 24–32pt — beyond that it stops aiding readability and starts -/// competing with the title. The fit scale multiplies title and bullets -/// alike, so capping the bullet size also keeps the hierarchy intact. -const double _kBulletMaxFontFraction = 0.0335; - -/// The largest auto-fit scale that keeps bullets at or under -/// [_kBulletMaxFontFraction], given the layout's own [layoutMax] growth bound. -double _bulletScaleCap(double w, double bulletSize, double layoutMax) => - math.min(layoutMax, _kBulletMaxFontFraction * w / bulletSize); - -/// Line height used for bullet body text, shared by rendering and measuring. -const double _kBulletLineHeight = 1.16; - -String _bulletMarkerForLevel(int level) { - const markers = ['•', '◦', '▪', '▫', '–']; - return markers[level.clamp(0, markers.length - 1)]; -} - -String _listMarker(List items, int index, ListStyle style) { - int levelOf(String item) { - var level = 0; - while (level < item.length && item[level] == '\t') { - level++; - } - return level; - } - - final level = levelOf(items[index]); - if (style == ListStyle.bullets) return _bulletMarkerForLevel(level); - if (style == ListStyle.checklist) { - return checklistItemChecked(items[index]) ? '☑' : '☐'; - } - var number = 0; - for (var i = 0; i <= index; i++) { - final itemLevel = levelOf(items[i]); - if (itemLevel == level) number++; - if (itemLevel < level) number = 0; - } - return '$number.'; -} - -double _bulletLevelScale(int level) { - if (level <= 0) return 1.0; - if (level == 1) return 0.86; - if (level == 2) return 0.80; - return 0.76; -} - -/// Largest scale in [minScale, maxScale] for which the bullet block fits -/// [availH] at the full column width. Unlike a plain `BoxFit.scaleDown`, this -/// also grows the text *above* its design size when there is spare vertical -/// room, so short slides use the full height instead of clustering at the top. -double _bulletsFitScale({ - required double availW, - required double availH, - required bool hasTitle, - required String title, - required List bullets, - required double titleSize, - required double bulletSize, - required double spacing, - required double bulletGap, - required String font, - String subtitle = '', - double subtitleSize = 0, - double minScale = 0.2, - double maxScale = 1.0, - ListStyle listStyle = ListStyle.bullets, -}) { - if (availW <= 0 || !availH.isFinite || availH <= 0) return 1.0; - // 2% safety margin so minor measurement differences never overflow. - final budget = availH * 0.98; - double measure(double scale) => _bulletsBlockHeight( - scale: scale, - availW: availW, - listStyle: listStyle, - hasTitle: hasTitle, - title: title, - bullets: bullets, - titleSize: titleSize, - bulletSize: bulletSize, - spacing: spacing, - bulletGap: bulletGap, - font: font, - subtitle: subtitle, - subtitleSize: subtitleSize, - ); - - // Everything already fits at the largest allowed size → use it. - if (measure(maxScale) <= budget) return maxScale; - - // Otherwise binary-search the largest scale that fits. Search upward from the - // design size when it fits, downward when even the design size overflows. - double lo, hi; - if (maxScale > 1.0 && measure(1.0) <= budget) { - lo = 1.0; - hi = maxScale; - } else { - lo = minScale; - hi = maxScale > 1.0 ? 1.0 : maxScale; - } - for (var i = 0; i < 24; i++) { - final mid = (lo + hi) / 2; - if (measure(mid) <= budget) { - lo = mid; - } else { - hi = mid; - } - } - return lo; -} - -double _bulletsBlockHeight({ - required double scale, - required double availW, - required bool hasTitle, - required String title, - required List bullets, - required double titleSize, - required double bulletSize, - required double spacing, - required double bulletGap, - required String font, - String subtitle = '', - double subtitleSize = 0, - ListStyle listStyle = ListStyle.bullets, -}) { - var height = 0.0; - if (hasTitle) { - height += _measureTextHeight( - title, - titleSize * scale, - availW, - bold: true, - fontFamily: font, - ); - } - if (subtitle.isNotEmpty) { - height += spacing * scale * 0.4; - height += _measureTextHeight( - subtitle, - subtitleSize * scale, - availW, - bold: true, - fontFamily: font, - ); - } - if ((hasTitle || subtitle.isNotEmpty) && bullets.isNotEmpty) { - height += spacing * scale; - } - for (var i = 0; i < bullets.length; i++) { - final b = bullets[i]; - int level = 0; - while (level < b.length && b[level] == '\t') { - level++; - } - // Measure exactly what gets rendered: checklists strip the `[x] ` prefix - // and use a checkbox marker, numbered lists use `N.`. Measuring the raw - // string with a bullet marker over-counts the height and would shrink the - // text below the space it actually needs. - final text = listStyle == ListStyle.checklist - ? checklistItemText(b) - : b.substring(level); - final fontSize = bulletSize * _bulletLevelScale(level) * scale; - final indent = level * bulletSize * 1.05 * scale; - final marker = '${_listMarker(bullets, i, listStyle)} '; - final markerW = _measureTextWidth( - marker, - fontSize, - bold: true, - fontFamily: font, - ); - final wrapW = (availW - indent - markerW).clamp(1.0, availW); - final textH = _measureTextHeight( - text, - fontSize, - wrapW, - lineHeight: _kBulletLineHeight, - fontFamily: font, - ); - final markerH = _measureTextHeight( - marker, - fontSize, - double.infinity, - fontFamily: font, - ); - height += bulletGap * scale * 2 + (textH > markerH ? textH : markerH); - } - return height; -} - -double _measureTextHeight( - String text, - double fontSize, - double maxWidth, { - double? lineHeight, - bool bold = false, - String? fontFamily, -}) { - final painter = TextPainter( - text: TextSpan( - text: stripInlineMarkdown(text), - style: TextStyle( - fontFamily: fontFamily, - fontSize: fontSize, - height: lineHeight, - fontWeight: bold ? FontWeight.bold : null, - ), - ), - textDirection: TextDirection.ltr, - )..layout(maxWidth: maxWidth.isFinite ? maxWidth : double.infinity); - return painter.height; -} - -double _measureTextWidth( - String text, - double fontSize, { - bool bold = false, - String? fontFamily, -}) { - final painter = TextPainter( - text: TextSpan( - text: stripInlineMarkdown(text), - style: TextStyle( - fontFamily: fontFamily, - fontSize: fontSize, - fontWeight: bold ? FontWeight.bold : null, - ), - ), - textDirection: TextDirection.ltr, - )..layout(); - return painter.width; -} - -class _TwoImagesPreview extends StatelessWidget { - final Slide slide; - final double w; - final String? projectPath; - final String font; - final ThemeProfile profile; - - const _TwoImagesPreview({ - required this.slide, - required this.w, - this.projectPath, - required this.font, - required this.profile, - }); - - @override - Widget build(BuildContext context) { - final splitFraction = (slide.imageSize > 0 ? slide.imageSize / 100.0 : 0.5) - .clamp(0.1, 0.9); - final leftW = w * splitFraction; - final rightW = w * (1 - splitFraction); - final titleSize = w * 0.032; - - return Container( - color: _hexColor(profile.slideBackgroundColor), - child: Stack( - fit: StackFit.expand, - children: [ - // Twee afbeeldingen naast elkaar - Row( - children: [ - SizedBox( - width: leftW, - child: Stack( - fit: StackFit.expand, - children: [ - _resolvedImage(context, slide.imagePath, projectPath), - _captionOverlay(context, slide.imageCaption, w), - ], - ), - ), - SizedBox( - width: rightW, - child: Stack( - fit: StackFit.expand, - children: [ - _resolvedImage(context, slide.imagePath2, projectPath), - _captionOverlay(context, slide.imageCaption2, w), - ], - ), - ), - ], - ), - // Optionele ondertitel - if (slide.title.isNotEmpty) - Positioned( - left: 0, - right: 0, - bottom: w * 0.04, - child: Container( - color: Colors.black54, - padding: EdgeInsets.symmetric( - horizontal: w * 0.04, - vertical: w * 0.015, - ), - child: _md( - context, - slide.title, - _applyFont( - font, - TextStyle( - color: Colors.white, - fontSize: titleSize, - fontWeight: FontWeight.w500, - ), - ), - linkColor: const Color(0xFF8BB8FF), - textAlign: TextAlign.center, - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - ), - ), - ], - ), - ); - } -} - -class _ImagePreview extends StatelessWidget { - final Slide slide; - final double w; - final String? projectPath; - final String font; - final ThemeProfile profile; - - const _ImagePreview({ - required this.slide, - required this.w, - this.projectPath, - required this.font, - required this.profile, - }); - - @override - Widget build(BuildContext context) { - final hasTitle = slide.title.isNotEmpty; - return Stack( - fit: StackFit.expand, - children: [ - _zoomedImage( - context, - slide.imagePath, - projectPath, - slide.imageSize, - bgColor: _hexColor(profile.slideBackgroundColor), - // When zoomed out, anchor the image to the top so the bottom title - // banner sits in the freed-up space instead of over the picture. - alignment: hasTitle ? Alignment.topCenter : Alignment.center, - ), - if (slide.title.isNotEmpty) - Positioned( - left: w * 0.06, - right: w * 0.06, - bottom: w * 0.06, - child: Container( - padding: EdgeInsets.symmetric( - horizontal: w * 0.04, - vertical: w * 0.02, - ), - decoration: BoxDecoration( - color: Colors.black54, - borderRadius: BorderRadius.circular(4), - ), - child: _md( - context, - slide.title, - _applyFont( - font, - TextStyle( - color: Colors.white, - fontSize: w * 0.038, - fontWeight: FontWeight.bold, - ), - ), - linkColor: const Color(0xFF8BB8FF), - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - ), - ), - _captionOverlay(context, slide.imageCaption, w), - ], - ); - } -} - -class _VideoPreview extends StatefulWidget { - final Slide slide; - final double w; - final String? projectPath; - final String font; - final ThemeProfile profile; - final bool autoplay; - final VoidCallback? onComplete; - - const _VideoPreview({ - required this.slide, - required this.w, - this.projectPath, - required this.font, - required this.profile, - this.autoplay = false, - this.onComplete, - }); - - @override - State<_VideoPreview> createState() => _VideoPreviewState(); -} - -class _VideoPreviewState extends State<_VideoPreview> { - VideoPlayerController? _controller; - String? _path; - bool _completed = false; - - @override - void initState() { - super.initState(); - _init(); - } - - @override - void didUpdateWidget(_VideoPreview oldWidget) { - super.didUpdateWidget(oldWidget); - if (oldWidget.slide.videoPath != widget.slide.videoPath || - oldWidget.autoplay != widget.autoplay) { - _init(); - } - } - - Future _init() async { - _controller?.removeListener(_onTick); - await _controller?.dispose(); - _controller = null; - _completed = false; - _path = _resolvePath(widget.slide.videoPath, widget.projectPath); - if (_path == null) { - if (mounted) setState(() {}); - return; - } - final controller = VideoPlayerController.file(File(_path!)); - _controller = controller; - try { - await controller.initialize(); - controller.addListener(_onTick); - await controller.setLooping(false); - if (widget.autoplay) await controller.play(); - } catch (_) { - // Keep the placeholder visible when the platform cannot open the file. - } - if (mounted) setState(() {}); - } - - void _onTick() { - final controller = _controller; - if (controller == null || - !controller.value.isInitialized || - _completed || - !widget.autoplay) { - return; - } - final duration = controller.value.duration; - final position = controller.value.position; - if (duration > Duration.zero && - position.inMilliseconds >= duration.inMilliseconds - 200 && - !controller.value.isPlaying) { - _completed = true; - widget.onComplete?.call(); - } - } - - @override - void dispose() { - _controller?.removeListener(_onTick); - _controller?.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final controller = _controller; - return Container( - color: _hexColor(widget.profile.slideBackgroundColor), - child: Stack( - fit: StackFit.expand, - children: [ - if (controller != null && controller.value.isInitialized) - Center( - child: AspectRatio( - aspectRatio: controller.value.aspectRatio, - child: VideoPlayer(controller), - ), - ) - else - _mediaPlaceholder(Icons.movie_outlined, 'Video'), - if (widget.slide.title.isNotEmpty) - Positioned( - left: widget.w * 0.06, - right: widget.w * 0.06, - top: widget.w * 0.04, - child: _md( - context, - widget.slide.title, - _applyFont( - widget.font, - TextStyle( - color: _hexColor(widget.profile.textColor), - fontSize: widget.w * 0.038, - fontWeight: FontWeight.bold, - ), - ), - linkColor: _hexColor(widget.profile.accentColor), - ), - ), - Positioned( - left: widget.w * 0.04, - bottom: widget.w * 0.035, - child: IconButton( - onPressed: controller == null || !controller.value.isInitialized - ? null - : () { - setState(() { - controller.value.isPlaying - ? controller.pause() - : controller.play(); - }); - }, - icon: Icon( - controller?.value.isPlaying == true - ? Icons.pause_circle - : Icons.play_circle, - ), - iconSize: widget.w * 0.045, - ), - ), - ], - ), - ); - } -} - -class _QuotePreview extends StatelessWidget { - final Slide slide; - final double w; - final String font; - final String? projectPath; - final ThemeProfile profile; - - const _QuotePreview({ - required this.slide, - required this.w, - required this.font, - this.projectPath, - required this.profile, - }); - - @override - Widget build(BuildContext context) { - final pad = w * 0.08; - final hasBg = slide.imagePath.isNotEmpty; - final textColor = hasBg ? Colors.white : _hexColor(profile.textColor); - final authorColor = hasBg ? Colors.white70 : Colors.grey[600]!; - final accentColor = hasBg ? Colors.white : _hexColor(profile.accentColor); - - final content = FittedBox( - fit: BoxFit.scaleDown, - alignment: Alignment.center, - child: SizedBox( - width: w, - child: Padding( - padding: EdgeInsets.all(pad), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Container( - width: w * 0.008, - height: w * 0.12, - color: accentColor, - margin: EdgeInsets.only(right: pad * 0.4), - ), - Expanded( - child: _md( - context, - slide.quote.isEmpty ? '' : '"${slide.quote}"', - _applyFont( - font, - TextStyle( - fontSize: w * 0.033, - fontStyle: FontStyle.italic, - color: textColor, - height: 1.4, - ), - ), - linkColor: accentColor, - ), - ), - ], - ), - if (slide.quoteAuthor.isNotEmpty) ...[ - SizedBox(height: pad * 0.6), - _md( - context, - '— ${slide.quoteAuthor}', - _applyFont( - font, - TextStyle( - fontSize: w * 0.026, - color: authorColor, - fontWeight: FontWeight.w500, - ), - ), - linkColor: accentColor, - ), - ], - ], - ), - ), - ), - ); - - if (!hasBg) { - return Container( - color: _hexColor(profile.slideBackgroundColor), - child: SizedBox.expand(child: content), - ); - } - - return Stack( - fit: StackFit.expand, - children: [ - _zoomedImage( - context, - slide.imagePath, - projectPath, - slide.imageSize, - bgColor: _hexColor(profile.slideBackgroundColor), - ), - Container(color: Colors.black.withValues(alpha: 0.52)), - content, - _captionOverlay(context, slide.imageCaption, w), - ], - ); - } -} - -class _LogoOverlay extends StatelessWidget { - final String logoPath; - final String? projectPath; - final String position; - final double size; - - const _LogoOverlay({ - required this.logoPath, - required this.projectPath, - required this.position, - required this.size, - }); - - @override - Widget build(BuildContext context) { - final horizontalInset = size * 0.28; - final topInset = size * 0.42; - final bottomInset = size * 0.12; - return Positioned( - top: position.startsWith('top') ? topInset : null, - bottom: position.startsWith('bottom') ? bottomInset : null, - left: position.endsWith('left') ? horizontalInset : null, - right: position.endsWith('right') ? horizontalInset : null, - child: SizedBox( - width: size, - height: size, - child: _resolvedImage( - context, - logoPath, - projectPath, - fit: BoxFit.contain, - ), - ), - ); - } -} - -class _MarkdownPreview extends StatelessWidget { - final Slide slide; - final double w; - final String font; - final ThemeProfile profile; - - const _MarkdownPreview({ - required this.slide, - required this.w, - required this.font, - required this.profile, - }); - - @override - Widget build(BuildContext context) { - final pad = w * 0.07; - final safe = slide.showLogo ? _logoSafeInsets(w, profile) : EdgeInsets.zero; - - return Container( - color: Colors.white, - child: SizedBox.expand( - child: FittedBox( - fit: BoxFit.scaleDown, - alignment: Alignment.topLeft, - child: SizedBox( - width: w, - child: Padding( - padding: EdgeInsets.fromLTRB( - pad, - pad + safe.top, - pad, - pad + safe.bottom, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: _buildBlocks(context), - ), - ), - ), - ), - ), - ); - } - - /// Parse the free Markdown into block widgets: fenced ```code``` (syntax - /// highlighted), `$$…$$` display math, and ordinary heading/bullet/text lines. - List _buildBlocks(BuildContext context) { - final link = _hexColor(profile.accentColor); - final lines = slide.customMarkdown.split('\n'); - final widgets = []; - var i = 0; - // Cap rendered blocks so a huge slide can't blow up layout (the preview is a - // thumbnail; FittedBox scales the rest down). - while (i < lines.length && widgets.length < 24) { - final line = lines[i]; - - // Fenced code block: ``` or ```language … ``` - final fence = RegExp(r'^\s*```(.*)$').firstMatch(line); - if (fence != null) { - final language = fence.group(1)!.trim(); - final code = []; - i++; - while (i < lines.length && !RegExp(r'^\s*```\s*$').hasMatch(lines[i])) { - code.add(lines[i]); - i++; - } - if (i < lines.length) i++; // consume the closing fence - widgets.add(_codeBlock(code.join('\n'), language)); - continue; - } - - // Display math fenced by lines containing only `$$`. - if (line.trim() == r'$$') { - final tex = []; - i++; - while (i < lines.length && lines[i].trim() != r'$$') { - tex.add(lines[i]); - i++; - } - if (i < lines.length) i++; // consume the closing $$ - widgets.add(_mathBlock(tex.join('\n'))); - continue; - } - // Single-line display math: $$ … $$ - final oneLine = RegExp(r'^\s*\$\$(.+)\$\$\s*$').firstMatch(line); - if (oneLine != null) { - widgets.add(_mathBlock(oneLine.group(1)!.trim())); - i++; - continue; - } - - widgets.add(_textLine(context, line, link)); - i++; - } - return widgets; - } - - Widget _textLine(BuildContext context, String line, Color link) { - if (line.startsWith('# ')) { - return _md( - context, - line.substring(2), - _applyFont( - font, - TextStyle( - fontSize: w * 0.04, - fontWeight: FontWeight.bold, - color: AppTheme.navy, - ), - ), - linkColor: link, - ); - } else if (line.startsWith('## ')) { - return _md( - context, - line.substring(3), - _applyFont( - font, - TextStyle(fontSize: w * 0.03, fontWeight: FontWeight.w600), - ), - linkColor: link, - ); - } else if (line.startsWith('- ')) { - return _md( - context, - '• ${line.substring(2)}', - _applyFont(font, TextStyle(fontSize: w * 0.024)), - linkColor: link, - ); - } else if (line.isEmpty) { - return SizedBox(height: w * 0.01); - } - return _md( - context, - line, - _applyFont(font, TextStyle(fontSize: w * 0.024)), - linkColor: link, - ); - } - - Widget _codeBlock(String code, String language) { - _ensureHighlightLanguages(); - final mono = TextStyle( - fontFamily: 'monospace', - fontSize: w * 0.02, - height: 1.3, - color: const Color(0xFF24292E), - ); - // HighlightView throws on an unregistered language, so only use it for ones - // we actually know; otherwise fall back to plain monospace. - final known = language.isNotEmpty && allLanguages.containsKey(language); - final Widget content = known - ? HighlightView( - code, - language: language, - theme: githubTheme, - padding: EdgeInsets.zero, - textStyle: mono, - ) - : Text(code, style: mono); - return Container( - width: double.infinity, - margin: EdgeInsets.symmetric(vertical: w * 0.008), - padding: EdgeInsets.all(w * 0.018), - decoration: BoxDecoration( - color: const Color(0xFFF6F8FA), - borderRadius: BorderRadius.circular(w * 0.008), - border: Border.all(color: const Color(0xFFE1E4E8)), - ), - child: content, - ); - } - - Widget _mathBlock(String tex) { - return Padding( - padding: EdgeInsets.symmetric(vertical: w * 0.012), - child: Math.tex( - tex, - textStyle: _applyFont(font, TextStyle(fontSize: w * 0.032)), - onErrorFallback: (err) => Text( - '\$\$$tex\$\$', - style: TextStyle( - fontFamily: 'monospace', - fontSize: w * 0.022, - color: Colors.red, - ), - ), - ), - ); - } -} - -/// Een 'broncode-sheet': de code op een donker editor-vlak, met -/// syntaxkleuring wanneer een taal bekend is. De tekst blijft platte tekst maar -/// wordt monospace en gekleurd weergegeven. Past zich met een FittedBox aan de -/// slide aan zodat lange fragmenten netjes verkleinen i.p.v. af te kappen. -class _CodePreview extends StatelessWidget { - final Slide slide; - final double w; - final String font; - final ThemeProfile profile; - - const _CodePreview({ - required this.slide, - required this.w, - required this.font, - required this.profile, - }); - - /// Natural (unwrapped) size of [text] in [style]: width is the longest line, - /// height the full block. Used to scale code to the available space. - static Size _measureMono(String text, TextStyle style) { - final painter = TextPainter( - text: TextSpan(text: text.isEmpty ? ' ' : text, style: style), - textDirection: TextDirection.ltr, - )..layout(); - return painter.size; - } - - @override - Widget build(BuildContext context) { - _ensureHighlightLanguages(); - final pad = w * 0.05; - final safe = slide.showLogo ? _logoSafeInsets(w, profile) : EdgeInsets.zero; - final code = slide.customMarkdown; - final lang = slide.codeLanguage.trim(); - final known = lang.isNotEmpty && allLanguages.containsKey(lang); - - final codeBg = _hexColor(profile.codeBackgroundColor); - final codeFg = _hexColor(profile.codeTextColor); - - // The chosen monospace family, always backed by a generic monospace fallback - // so an uninstalled face still renders fixed-width. - final fallback = ['Menlo', 'Consolas', 'Courier New', 'monospace'] - ..removeWhere((f) => f == profile.codeFontFamily); - final baseFont = w * 0.024; - final maxFont = w * 0.040; // grow to fill, but never huge - TextStyle monoAt(double size) => TextStyle( - fontFamily: profile.codeFontFamily, - fontFamilyFallback: fallback, - fontSize: size, - height: 1.4, - color: codeFg, - ); - - // HighlightView throws on an unknown language, so fall back to plain (but - // monospace) text. When syntax highlighting is off we always render plain - // text so the whole block is one colour — needed for a CRT-green screen. - final useHighlight = known && profile.codeHighlightSyntax; - final highlightTheme = { - ...atomOneDarkTheme, - // Keep atom-one-dark's per-token colours but drop its own background so - // our themed [codeBg] shows through unchanged. - 'root': (atomOneDarkTheme['root'] ?? const TextStyle()).copyWith( - backgroundColor: codeBg, - color: codeFg, - ), - }; - Widget buildCode(TextStyle style) => useHighlight - ? HighlightView( - code, - language: lang, - theme: highlightTheme, - padding: EdgeInsets.zero, - textStyle: style, - ) - : Text(code, style: style); - - return Container( - color: _hexColor(profile.slideBackgroundColor), - child: Padding( - padding: EdgeInsets.fromLTRB( - pad, - pad + safe.top, - pad, - pad + safe.bottom, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - // The slide title belongs to the slide, not inside the code window, - // so it sits above the panel like other slide types. - if (slide.title.isNotEmpty) ...[ - Container( - width: double.infinity, - padding: EdgeInsets.symmetric( - horizontal: w * 0.025, - vertical: w * 0.01, - ), - decoration: BoxDecoration( - color: _hexColor(profile.titleBackgroundColor), - borderRadius: BorderRadius.circular(w * 0.012), - border: Border( - left: BorderSide( - color: _hexColor(profile.accentColor), - width: w * 0.006, - ), - ), - ), - child: _md( - context, - slide.title, - _applyFont( - font, - TextStyle( - fontSize: w * 0.032, - height: 1.1, - fontWeight: FontWeight.bold, - color: _hexColor(profile.titleTextColor), - ), - ), - linkColor: _hexColor(profile.accentColor), - ), - ), - SizedBox(height: w * 0.018), - ], - Expanded( - child: Container( - width: double.infinity, - decoration: BoxDecoration( - color: codeBg, - borderRadius: BorderRadius.circular(w * 0.012), - border: Border.all(color: codeFg.withValues(alpha: 0.22)), - ), - padding: EdgeInsets.all(w * 0.03), - child: LayoutBuilder( - builder: (context, constraints) { - // Size the code to fill the panel: scale up to use spare - // space (capped at [maxFont]) and down so long fragments - // still fit, rather than leaving a small block in a big box. - final measured = useHighlight - ? code.replaceAll('\t', ' ') - : code; - final natural = _measureMono(measured, monoAt(baseFont)); - final availW = math.max(1.0, constraints.maxWidth - 1); - final availH = math.max(1.0, constraints.maxHeight - 1); - var scale = math.min( - availW / natural.width, - availH / natural.height, - ); - if (!scale.isFinite || scale <= 0) scale = 1; - final size = math.min(baseFont * scale, maxFont); - return Align( - alignment: Alignment.topLeft, - child: buildCode(monoAt(size)), - ); - }, - ), - ), - ), - ], - ), - ), - ); - } -} - -/// Renders a chart slide (bar/line/pie) from its ```chart JSON spec. -class _ChartPreview extends StatefulWidget { - final Slide slide; - final double w; - final String font; - final ThemeProfile profile; - final bool presentationMode; - - const _ChartPreview({ - required this.slide, - required this.w, - required this.font, - required this.profile, - required this.presentationMode, - }); - - @override - State<_ChartPreview> createState() => _ChartPreviewState(); -} - -class _ChartPreviewState extends State<_ChartPreview> { - Slide get slide => widget.slide; - double get w => widget.w; - String get font => widget.font; - ThemeProfile get profile => widget.profile; - bool get presentationMode => widget.presentationMode; - - /// Legend entry the pointer is over: a series index for bar/line charts, or a - /// slice (category) index for pie charts. Null when nothing is hovered. - int? _hovered; - - /// The radar vertex under the pointer, used to draw its tooltip. Null when not - /// hovering a point. - ({int series, int entry, double value, Offset offset})? _radarTouch; - - void _setHover(int? index) { - if (_hovered != index) setState(() => _hovered = index); - } - - /// True when another legend entry is hovered, so [index] should fade back. - bool _dimmed(int index) => _hovered != null && _hovered != index; - - /// Series colour with legend-hover feedback: non-hovered series fade out so - /// the hovered one stands out in the plot. - Color _seriesDisplayColor(ChartSeries series, int i) { - final base = _seriesColor(series, i); - return _dimmed(i) ? base.withValues(alpha: 0.2) : base; - } - - double get _labelScale => presentationMode ? 1.12 : 1; - - Color _seriesColor(ChartSeries series, int i) { - if (series.color == null && i == 0) { - return _hexColor(profile.accentColor); - } - return _hexColor(chartSeriesColor(series, i)); - } - - /// Text alternative for the chart (WCAG 1.1.1): chart type, title and the - /// underlying values per series, so a screen reader conveys the same - /// information the visual encodes. - String _semanticsLabel(BuildContext context, ChartSpec spec) { - final l10n = context.l10n; - final typeName = switch (spec.type) { - ChartType.bar => l10n.d('Staaf'), - ChartType.line => l10n.d('Lijn'), - ChartType.pie => l10n.d('Cirkel'), - ChartType.radar => l10n.d('Spider'), - }; - final buffer = StringBuffer('${l10n.d('Grafiek')} ($typeName)'); - if (spec.title.isNotEmpty) { - buffer.write(': ${stripInlineMarkdown(spec.title)}'); - } - if (!spec.hasInlineData) return buffer.toString(); - for (var si = 0; si < spec.series.length; si++) { - final series = spec.series[si]; - final name = series.name.isEmpty - ? '${l10n.d('Reeks')} ${si + 1}' - : series.name; - final values = [ - for (var xi = 0; xi < spec.x.length && xi < series.data.length; xi++) - '${spec.x[xi]} ${_fmtNum(series.data[xi])}', - ]; - buffer.write('. $name: ${values.join(', ')}'); - } - return buffer.toString(); - } - - @override - Widget build(BuildContext context) { - final spec = ChartSpec.parse(slide.customMarkdown); - final horizontalPad = w * 0.05; - final verticalPad = w * 0.018; - final safe = slide.showLogo ? _logoSafeInsets(w, profile) : EdgeInsets.zero; - final textColor = _hexColor(profile.textColor); - - return Semantics( - image: true, - label: _semanticsLabel(context, spec), - // The visual chart (axis labels, legend chips, tooltips) would read as - // disconnected fragments; the label above carries the full story. - child: ExcludeSemantics( - child: _chartBody( - context, - spec, - horizontalPad, - verticalPad, - safe, - textColor, - ), - ), - ); - } - - Widget _chartBody( - BuildContext context, - ChartSpec spec, - double horizontalPad, - double verticalPad, - EdgeInsets safe, - Color textColor, - ) { - return Container( - color: _hexColor(profile.slideBackgroundColor), - child: Padding( - padding: EdgeInsets.fromLTRB( - horizontalPad, - verticalPad + safe.top, - horizontalPad, - verticalPad + safe.bottom, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - if (spec.title.isNotEmpty) ...[ - Container( - width: double.infinity, - padding: EdgeInsets.symmetric( - horizontal: w * 0.025, - vertical: w * 0.01, - ), - decoration: BoxDecoration( - color: _hexColor(profile.titleBackgroundColor), - borderRadius: BorderRadius.circular(w * 0.012), - border: Border( - left: BorderSide( - color: _hexColor(profile.accentColor), - width: w * 0.006, - ), - ), - ), - child: _md( - context, - spec.title, - _applyFont( - font, - TextStyle( - fontSize: w * 0.032, - height: 1.1, - fontWeight: FontWeight.bold, - color: _hexColor(profile.titleTextColor), - ), - ), - linkColor: _hexColor(profile.accentColor), - ), - ), - SizedBox(height: w * 0.012), - ], - Expanded( - child: Container( - key: const ValueKey('chart-surface'), - padding: EdgeInsets.fromLTRB( - w * 0.02, - w * 0.01, - w * 0.025, - w * 0.01, - ), - decoration: BoxDecoration( - color: textColor.withValues(alpha: 0.035), - borderRadius: BorderRadius.circular(w * 0.014), - border: Border.all(color: textColor.withValues(alpha: 0.09)), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Expanded( - child: spec.hasInlineData - ? _chart(spec, textColor) - : _placeholder(context), - ), - if (spec.hasInlineData && spec.series.isNotEmpty) ...[ - SizedBox(height: w * 0.006), - spec.type == ChartType.pie - ? _pieLegend(spec, textColor) - : _legend(spec, textColor), - ], - ], - ), - ), - ), - ], - ), - ), - ); - } - - Widget _legend(ChartSpec spec, Color textColor) { - return SizedBox( - height: w * 0.03, - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: Row( - children: [ - for (var i = 0; i < spec.series.length; i++) ...[ - if (i > 0) SizedBox(width: w * 0.01), - MouseRegion( - onEnter: (_) => _setHover(i), - onExit: (_) => _setHover(null), - child: AnimatedOpacity( - duration: const Duration(milliseconds: 120), - opacity: _dimmed(i) ? 0.4 : 1, - child: Container( - padding: EdgeInsets.symmetric( - horizontal: w * 0.01, - vertical: w * 0.004, - ), - decoration: BoxDecoration( - color: _hovered == i - ? _seriesColor( - spec.series[i], - i, - ).withValues(alpha: 0.18) - : textColor.withValues(alpha: 0.045), - borderRadius: BorderRadius.circular(w), - border: Border.all( - color: _hovered == i - ? _seriesColor(spec.series[i], i) - : Colors.transparent, - width: w * 0.0015, - ), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Container( - width: w * 0.012, - height: w * 0.012, - decoration: BoxDecoration( - color: _seriesColor(spec.series[i], i), - shape: BoxShape.circle, - ), - ), - SizedBox(width: w * 0.006), - ConstrainedBox( - constraints: BoxConstraints(maxWidth: w * 0.16), - child: Text( - spec.series[i].name.isEmpty - ? 'Reeks ${i + 1}' - : spec.series[i].name, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: _applyFont( - font, - TextStyle( - fontSize: w * 0.013, - fontWeight: FontWeight.w600, - color: textColor.withValues(alpha: 0.82), - ), - ), - ), - ), - ], - ), - ), - ), - ), - ], - ], - ), - ), - ); - } - - Widget _pieLegend(ChartSpec spec, Color textColor) { - final itemCount = math.min(spec.x.length, 18); - final columns = math.min(itemCount, presentationMode ? 4 : 6); - final rows = (itemCount / columns).ceil(); - return LayoutBuilder( - builder: (context, constraints) { - final gap = w * 0.006; - final itemWidth = - (constraints.maxWidth - gap * (columns - 1)) / columns; - return SizedBox( - height: rows * w * 0.03 * _labelScale + (rows - 1) * gap, - child: Wrap( - spacing: gap, - runSpacing: gap, - children: [ - for (var i = 0; i < itemCount; i++) - MouseRegion( - onEnter: (_) => _setHover(i), - onExit: (_) => _setHover(null), - child: AnimatedOpacity( - duration: const Duration(milliseconds: 120), - opacity: _dimmed(i) ? 0.4 : 1, - child: Container( - width: itemWidth, - height: w * 0.03 * _labelScale, - padding: EdgeInsets.symmetric(horizontal: w * 0.008), - decoration: BoxDecoration( - color: _hovered == i - ? _hexColor( - chartRowColor(spec, i), - ).withValues(alpha: 0.18) - : textColor.withValues(alpha: 0.045), - borderRadius: BorderRadius.circular(w), - border: Border.all( - color: _hovered == i - ? _hexColor(chartRowColor(spec, i)) - : Colors.transparent, - width: w * 0.0015, - ), - ), - child: Row( - children: [ - Container( - width: w * 0.012, - height: w * 0.012, - decoration: BoxDecoration( - color: _hexColor(chartRowColor(spec, i)), - shape: BoxShape.circle, - ), - ), - SizedBox(width: w * 0.006), - Expanded( - child: Text( - spec.x[i], - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: _applyFont( - font, - TextStyle( - fontSize: w * 0.013 * _labelScale, - fontWeight: FontWeight.w600, - color: textColor.withValues(alpha: 0.82), - ), - ), - ), - ), - ], - ), - ), - ), - ), - ], - ), - ); - }, - ); - } - - Widget _chart(ChartSpec spec, Color textColor) { - switch (spec.type) { - case ChartType.bar: - return _barChart(spec, textColor); - case ChartType.line: - return _lineChart(spec, textColor); - case ChartType.pie: - return _pieChart(spec, textColor); - case ChartType.radar: - return _radarChart(spec, textColor); - } - } - - double _maxY(ChartSpec spec) { - var m = 0.0; - for (final s in spec.series) { - for (final v in s.data) { - if (v > m) m = v; - } - } - // Keep any bound line comfortably inside the plot so its label is visible. - if (spec.supportsBounds) { - for (final b in [spec.minBound, spec.maxBound]) { - if (b != null && b > m) m = b; - } - } - return m <= 0 ? 1 : m * 1.15; - } - - double _minY(ChartSpec spec) { - var m = 0.0; - for (final s in spec.series) { - for (final v in s.data) { - if (v < m) m = v; - } - } - if (spec.supportsBounds) { - for (final b in [spec.minBound, spec.maxBound]) { - if (b != null && b < m) m = b; - } - } - return m >= 0 ? 0 : m * 1.15; - } - - /// Optional min/max threshold lines drawn across the plot (bar/line only). - ExtraLinesData _boundLines(ChartSpec spec) { - if (!spec.supportsBoundLines) return const ExtraLinesData(); - final dash = [ - (w * 0.018).round().clamp(4, 14), - (w * 0.01).round().clamp(3, 9), - ]; - HorizontalLine line(double value, Color color, String prefix) => - HorizontalLine( - y: value, - color: color, - strokeWidth: w * 0.0035, - dashArray: dash, - label: HorizontalLineLabel( - show: true, - alignment: Alignment.topRight, - padding: EdgeInsets.only(right: w * 0.006, bottom: w * 0.002), - style: _applyFont( - font, - TextStyle( - fontSize: w * 0.0115 * _labelScale, - color: color, - fontWeight: FontWeight.w700, - ), - ), - labelResolver: (_) => '$prefix ${_fmtNum(value)}', - ), - ); - return ExtraLinesData( - horizontalLines: [ - if (spec.minBound != null) - line(spec.minBound!, const Color(0xFFF59E0B), 'min'), - if (spec.maxBound != null) - line(spec.maxBound!, const Color(0xFFEF4444), 'max'), - ], - ); - } - - FlTitlesData _titles(ChartSpec spec, Color textColor, {bool bars = false}) { - final style = _applyFont( - font, - TextStyle( - fontSize: w * 0.0115 * _labelScale, - color: textColor.withValues(alpha: 0.88), - fontWeight: presentationMode ? FontWeight.w600 : FontWeight.normal, - ), - ); - return FlTitlesData( - topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)), - rightTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)), - leftTitles: AxisTitles( - sideTitles: SideTitles( - showTitles: true, - reservedSize: w * 0.05 * _labelScale, - getTitlesWidget: (value, meta) => Text( - _fmtNum(value), - style: style.copyWith(fontSize: w * 0.0105 * _labelScale), - ), - ), - ), - bottomTitles: AxisTitles( - sideTitles: SideTitles( - showTitles: true, - interval: 1, - reservedSize: w * 0.044 * _labelScale, - getTitlesWidget: (value, meta) { - final i = value.round(); - final n = spec.x.length; - if (i < 0 || i >= n) return const SizedBox.shrink(); - // Show as many labels as fit without colliding: keep at least - // [minSlot] of horizontal room per label, then thin them out - // evenly based on the actual pixel spacing between points. Line - // charts spread n points over n-1 intervals; bar groups are laid - // out spaceEvenly, which puts their centres (axis + groupWidth) / - // (n + 1) apart. - final spacing = bars - ? (meta.parentAxisSize + _barGroupWidth(spec)) / (n + 1) - : (n > 1 ? meta.parentAxisSize / (n - 1) : meta.parentAxisSize); - final minSlot = w * 0.085 * _labelScale; - final step = math.max(1, (minSlot / spacing).ceil()); - final lastMultiple = ((n - 1) ~/ step) * step; - final lastGap = n - 1 - lastMultiple; - final showLast = i == n - 1 && lastGap > step / 2; - if (i % step != 0 && !showLast) return const SizedBox.shrink(); - // The extra end label can sit closer than a full step to its - // neighbour; shrink both of their slots to the real gap so they - // never run through each other. - var slotSteps = step.toDouble(); - if (showLast || (i == lastMultiple && lastGap > step / 2)) { - slotSteps = math.min(slotSteps, lastGap.toDouble()); - } - final slot = (slotSteps * spacing - w * 0.012).clamp( - w * 0.04, - w * 0.16, - ); - return Padding( - padding: EdgeInsets.only(top: w * 0.008), - child: SizedBox( - width: slot, - child: Text( - spec.x[i], - style: style, - maxLines: 2, - overflow: TextOverflow.ellipsis, - textAlign: TextAlign.center, - ), - ), - ); - }, - ), - ), - ); - } - - String _fmtNum(double v) { - if (v == v.roundToDouble()) return v.toInt().toString(); - return v.toStringAsFixed(1); - } - - FlGridData _grid(Color textColor) => FlGridData( - show: true, - drawVerticalLine: false, - getDrawingHorizontalLine: (v) => - FlLine(color: textColor.withValues(alpha: 0.12), strokeWidth: 1), - ); - - /// Width of one bar rod, shared by the chart and the axis-label spacing. - double _barRodWidth(ChartSpec spec) => - (w * 0.032 / spec.series.length).clamp(w * 0.008, w * 0.022); - - /// Total width of one bar group: its rods plus fl_chart's default 2px - /// spacing between rods within a group. - double _barGroupWidth(ChartSpec spec) { - final rods = math.max(1, spec.series.length); - return rods * _barRodWidth(spec) + (rods - 1) * 2; - } - - Widget _barChart(ChartSpec spec, Color textColor) { - final groups = []; - for (var xi = 0; xi < spec.x.length; xi++) { - groups.add( - BarChartGroupData( - x: xi, - barRods: [ - for (var si = 0; si < spec.series.length; si++) - if (xi < spec.series[si].data.length) - BarChartRodData( - toY: spec.series[si].data[xi], - color: _seriesDisplayColor(spec.series[si], si), - width: _barRodWidth(spec), - borderRadius: BorderRadius.vertical( - top: Radius.circular(w * 0.006), - ), - backDrawRodData: BackgroundBarChartRodData( - show: true, - toY: _maxY(spec), - color: textColor.withValues(alpha: 0.025), - ), - ), - ], - ), - ); - } - return BarChart( - BarChartData( - minY: _minY(spec), - maxY: _maxY(spec), - // The axis-label spacing in _titles assumes this layout; keep it - // explicit rather than relying on fl_chart's default. - alignment: BarChartAlignment.spaceEvenly, - barGroups: groups, - titlesData: _titles(spec, textColor, bars: true), - gridData: _grid(textColor), - borderData: FlBorderData(show: false), - extraLinesData: _boundLines(spec), - barTouchData: BarTouchData( - enabled: true, - mouseCursorResolver: (event, response) => response?.spot == null - ? SystemMouseCursors.basic - : SystemMouseCursors.click, - touchTooltipData: BarTouchTooltipData( - fitInsideHorizontally: true, - fitInsideVertically: true, - getTooltipColor: (_) => const Color(0xFF0F172A), - getTooltipItem: (group, groupIndex, rod, rodIndex) { - final label = group.x >= 0 && group.x < spec.x.length - ? spec.x[group.x] - : ''; - final series = rodIndex < spec.series.length - ? spec.series[rodIndex].name - : ''; - return BarTooltipItem( - '$label\n${series.isEmpty ? 'Reeks ${rodIndex + 1}' : series}: ${_fmtNum(rod.toY)}', - _tooltipStyle(), - ); - }, - ), - ), - ), - duration: Duration.zero, - ); - } - - Widget _lineChart(ChartSpec spec, Color textColor) { - final bars = []; - for (var si = 0; si < spec.series.length; si++) { - bars.add( - LineChartBarData( - spots: [ - for (var xi = 0; xi < spec.series[si].data.length; xi++) - FlSpot(xi.toDouble(), spec.series[si].data[xi]), - ], - color: _seriesDisplayColor(spec.series[si], si), - barWidth: w * (_hovered == si ? 0.0065 : 0.0045), - isCurved: true, - curveSmoothness: 0.22, - dotData: FlDotData( - show: true, - getDotPainter: (spot, percent, bar, index) => FlDotCirclePainter( - radius: w * 0.005, - color: _seriesDisplayColor(spec.series[si], si), - strokeWidth: w * 0.0025, - strokeColor: _hexColor(profile.slideBackgroundColor), - ), - ), - belowBarData: BarAreaData( - show: true, - color: _seriesDisplayColor( - spec.series[si], - si, - ).withValues(alpha: spec.series.length == 1 ? 0.14 : 0.05), - ), - ), - ); - } - return LineChart( - LineChartData( - minY: _minY(spec), - maxY: _maxY(spec), - lineBarsData: bars, - titlesData: _titles(spec, textColor), - gridData: _grid(textColor), - borderData: FlBorderData(show: false), - extraLinesData: _boundLines(spec), - lineTouchData: LineTouchData( - enabled: true, - // Measure proximity to the actual dot (x *and* y), not just the - // column, so the tooltip belongs to the point under the cursor. - distanceCalculator: (touch, spot) => (touch - spot).distance, - touchSpotThreshold: (w * 0.02).clamp(8.0, 24.0).toDouble(), - mouseCursorResolver: (event, response) => - response?.lineBarSpots?.isEmpty ?? true - ? SystemMouseCursors.basic - : SystemMouseCursors.click, - touchTooltipData: LineTouchTooltipData( - fitInsideHorizontally: true, - fitInsideVertically: true, - getTooltipColor: (_) => const Color(0xFF0F172A), - // Show every dot near the cursor. When several dots sit on (almost) - // the same spot they all appear; the font shrinks to keep them - // readable when stacked. - getTooltipItems: (spots) { - final style = _lineTooltipStyle(spots.length); - return [ - for (final spot in spots) - LineTooltipItem( - '${spot.spotIndex < spec.x.length ? spec.x[spot.spotIndex] : ''}\n' - '${spot.barIndex < spec.series.length && spec.series[spot.barIndex].name.isNotEmpty ? spec.series[spot.barIndex].name : 'Reeks ${spot.barIndex + 1}'}: ${_fmtNum(spot.y)}', - style, - ), - ]; - }, - ), - ), - ), - duration: Duration.zero, - ); - } - - Widget _pieChart(ChartSpec spec, Color textColor) { - if (spec.series.isEmpty || spec.x.isEmpty) { - return _placeholderText('—'); - } - return LayoutBuilder( - builder: (context, constraints) { - final visibleSeries = math.min(spec.series.length, 2); - final columns = visibleSeries; - const rows = 1; - final tileHeight = constraints.maxHeight / rows; - final tileWidth = constraints.maxWidth / columns; - return GridView.builder( - padding: EdgeInsets.zero, - physics: const NeverScrollableScrollPhysics(), - gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: columns, - childAspectRatio: tileWidth / tileHeight, - crossAxisSpacing: w * 0.012, - mainAxisSpacing: w * 0.008, - ), - itemCount: visibleSeries, - itemBuilder: (context, si) { - final series = spec.series[si]; - final values = [ - for (var xi = 0; xi < spec.x.length; xi++) - xi < series.data.length && series.data[xi] > 0 - ? series.data[xi] - : 0.0, - ]; - final total = values.fold(0, (a, b) => a + b); - return Row( - children: [ - Expanded( - flex: 4, - child: total <= 0 - ? Center( - child: Text( - '0', - style: _applyFont( - font, - TextStyle( - fontSize: w * 0.025, - color: textColor.withValues(alpha: 0.5), - ), - ), - ), - ) - : LayoutBuilder( - builder: (context, pieConstraints) { - final available = - pieConstraints.biggest.shortestSide; - final radius = (available * 0.42).clamp( - w * 0.018, - w * 0.075, - ); - return ClipRect( - child: _HoverPieChart( - externalHover: _hovered, - values: values, - labels: spec.x, - colors: [ - for (var xi = 0; xi < values.length; xi++) - _hexColor(chartRowColor(spec, xi)), - ], - radius: radius, - centerSpaceRadius: radius * 0.42, - sectionSpace: w * 0.002, - titleStyle: _applyFont( - font, - TextStyle( - fontSize: (radius * 0.18).clamp( - w * 0.009, - w * 0.013, - ), - color: Colors.white, - fontWeight: FontWeight.bold, - ), - ), - tooltipStyle: _tooltipStyle(), - ), - ); - }, - ), - ), - SizedBox(width: w * 0.008), - Expanded( - flex: 2, - child: Text( - series.name.isEmpty ? 'Reeks ${si + 1}' : series.name, - maxLines: 3, - overflow: TextOverflow.ellipsis, - style: _applyFont( - font, - TextStyle( - fontSize: w * 0.015, - height: 1.1, - fontWeight: FontWeight.w700, - color: textColor, - ), - ), - ), - ), - ], - ); - }, - ); - }, - ); - } - - Widget _radarChart(ChartSpec spec, Color textColor) { - if (spec.x.length < 3 || spec.series.isEmpty) { - return _placeholderText( - context.l10n.d('Een spider-diagram heeft minstens drie labels nodig'), - ); - } - final grid = textColor.withValues(alpha: 0.18); - final scale = radarScale(spec); - - return Padding( - padding: EdgeInsets.symmetric(horizontal: w * 0.02, vertical: w * 0.012), - child: LayoutBuilder( - builder: (context, constraints) { - // Reserve a slim column on the right for the scale legend; the rest - // of the area is shared between the spider and its axis labels. - final legendWidth = w * 0.075; - final boxW = math.max( - 0.0, - constraints.maxWidth - legendWidth - w * 0.02, - ); - final boxH = constraints.maxHeight; - if (boxW <= 0 || !boxH.isFinite || boxH <= 0) { - return const SizedBox.shrink(); - } - // Measure every axis label and grow the spider until the labels just - // fit between the polygon and the edges of the available area, so - // the diagram uses the space the old fixed label bands wasted. - final layout = _radarLabelLayout(spec, boxW, boxH, textColor); - final chartSide = layout.chartSide; - - return Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Expanded( - child: Center( - child: SizedBox( - width: boxW, - height: boxH, - child: Stack( - children: [ - for (var i = 0; i < spec.x.length; i++) - _radarAxisLabel( - label: spec.x[i], - index: i, - count: spec.x.length, - layout: layout, - textColor: textColor, - ), - Positioned( - left: (boxW - chartSide) / 2, - top: (boxH - chartSide) / 2, - width: chartSide, - height: chartSide, - child: Stack( - clipBehavior: Clip.none, - children: [ - Positioned.fill( - child: RadarChart( - RadarChartData( - dataSets: [ - for ( - var si = 0; - si < spec.series.length; - si++ - ) - RadarDataSet( - dataEntries: [ - for ( - var xi = 0; - xi < spec.x.length; - xi++ - ) - RadarEntry( - value: - xi < - spec - .series[si] - .data - .length - ? spec.series[si].data[xi] - : 0, - ), - ], - fillColor: - _seriesDisplayColor( - spec.series[si], - si, - ).withValues( - alpha: _dimmed(si) - ? 0.04 - : 0.16, - ), - borderColor: _seriesDisplayColor( - spec.series[si], - si, - ), - borderWidth: - w * - (_hovered == si - ? 0.0055 - : 0.0035), - entryRadius: - w * - (_hovered == si ? 0.006 : 0.004), - ), - // Invisible anchor pinning the scale to [lo, hi] - // so the rings represent a fixed scale. - RadarDataSet( - dataEntries: [ - for ( - var xi = 0; - xi < spec.x.length; - xi++ - ) - RadarEntry( - value: xi == 0 - ? scale.hi - : scale.lo, - ), - ], - fillColor: Colors.transparent, - borderColor: Colors.transparent, - borderWidth: 0, - entryRadius: 0, - ), - ], - radarShape: RadarShape.polygon, - radarBackgroundColor: Colors.transparent, - radarBorderData: BorderSide( - color: grid, - width: 1, - ), - gridBorderData: BorderSide( - color: grid, - width: 1, - ), - tickBorderData: BorderSide( - color: grid, - width: 1, - ), - tickCount: scale.ticks, - isMinValueAtCenter: true, - // The scale now lives in a side legend, so hide - // fl_chart's in-chart ring numbers. - ticksTextStyle: const TextStyle( - color: Colors.transparent, - fontSize: 0.001, - ), - titlePositionPercentageOffset: 0, - getTitle: (index, angle) => RadarChartTitle( - text: index < spec.x.length - ? spec.x[index] - : '', - ), - // Labels are rendered as constrained widgets - // around the chart so long text can wrap. - titleTextStyle: const TextStyle( - color: Colors.transparent, - fontSize: 0.001, - ), - radarTouchData: RadarTouchData( - enabled: true, - touchSpotThreshold: (w * 0.02) - .clamp(8.0, 24.0) - .toDouble(), - mouseCursorResolver: (event, response) => - _radarSpotFrom(response, spec) == null - ? SystemMouseCursors.basic - : SystemMouseCursors.click, - touchCallback: (event, response) { - final next = - event.isInterestedForInteractions - ? _radarSpotFrom(response, spec) - : null; - if (next != _radarTouch) { - setState(() => _radarTouch = next); - } - }, - ), - ), - duration: Duration.zero, - ), - ), - if (_radarTouch != null) - _radarTooltip(spec, chartSide, _radarTouch!), - ], - ), - ), - ], - ), - ), - ), - ), - SizedBox( - width: legendWidth, - child: _radarScaleLegend(scale, textColor), - ), - ], - ); - }, - ), - ); - } - - TextStyle _radarLabelStyle(int count, Color textColor) => _applyFont( - font, - TextStyle( - fontSize: w * (count <= 6 ? 0.013 : 0.0115) * _labelScale, - height: 1.05, - color: textColor.withValues(alpha: 0.88), - fontWeight: presentationMode ? FontWeight.w600 : FontWeight.w500, - ), - ); - - /// True when the vertex in [direction] gets its label placed beside the - /// polygon (left/right) rather than above/below it. - static bool _radarLabelBeside(Offset direction) => direction.dx.abs() > 0.35; - - /// Sizes the spider and places every axis label around it. - /// - /// Each label is measured at its real text size, then the polygon radius is - /// grown until the tightest label exactly fits between the polygon and the - /// edge of the [boxW]×[boxH] area. fl_chart draws the polygon at 0.4× the - /// side of its (square) widget, which is what ties [chartSide] to the - /// resulting radius. - ({double chartSide, List rects, List aligns, int maxLines}) - _radarLabelLayout(ChartSpec spec, double boxW, double boxH, Color textColor) { - const radiusFactor = 0.4; // fl_chart: radius = min(w, h) / 2 * 0.8 - final n = spec.x.length; - final style = _radarLabelStyle(n, textColor); - final gap = w * 0.008; - final maxLines = n <= 6 ? 3 : 2; - final sideCap = math.min(boxW * 0.28, w * 0.2); - final topCap = math.min(boxW * 0.5, w * 0.3); - - Size measure(String text, double maxWidth) { - final painter = TextPainter( - text: TextSpan(text: text, style: style), - textDirection: TextDirection.ltr, - maxLines: maxLines, - ellipsis: '…', - )..layout(maxWidth: math.max(0.0, maxWidth)); - final size = Size(painter.width, painter.height); - painter.dispose(); - return size; - } - - final directions = []; - final sizes = []; - for (var i = 0; i < n; i++) { - final angle = (2 * math.pi * i / n) - math.pi / 2; - final dir = Offset(math.cos(angle), math.sin(angle)); - directions.add(dir); - sizes.add(measure(spec.x[i], _radarLabelBeside(dir) ? sideCap : topCap)); - } - - // The largest polygon radius every label still fits next to. - var radius = radiusFactor * math.min(boxW, boxH); - for (var i = 0; i < n; i++) { - final dx = directions[i].dx.abs(); - final dy = directions[i].dy.abs(); - if (_radarLabelBeside(directions[i])) { - radius = math.min(radius, (boxW / 2 - gap - sizes[i].width) / dx); - if (dy > 0.01) { - radius = math.min(radius, (boxH / 2 - sizes[i].height / 2) / dy); - } - } else { - radius = math.min(radius, (boxH / 2 - gap - sizes[i].height) / dy); - if (dx > 0.01) { - radius = math.min(radius, (boxW / 2 - sizes[i].width / 2) / dx); - } - } - } - // Never let extreme labels crush the spider entirely; below this floor the - // labels get clamped (and ellipsized) instead. - final floor = 0.18 * math.min(boxW, boxH); - radius = radius.clamp( - math.min(floor, radiusFactor * math.min(boxW, boxH)), - radiusFactor * math.min(boxW, boxH), - ); - final chartSide = radius / radiusFactor; - - final center = Offset(boxW / 2, boxH / 2); - final rects = []; - final aligns = []; - for (var i = 0; i < n; i++) { - final dir = directions[i]; - final anchor = center + dir * (radius + gap); - var size = sizes[i]; - double left; - double top; - if (_radarLabelBeside(dir)) { - // Re-measure against the room actually left beside the polygon, so a - // clamped radius still produces a label that wraps inside the box. - final room = dir.dx > 0 ? boxW - anchor.dx : anchor.dx; - if (size.width > room) size = measure(spec.x[i], room); - left = dir.dx > 0 ? anchor.dx : anchor.dx - size.width; - top = anchor.dy - size.height / 2; - aligns.add(dir.dx > 0 ? TextAlign.left : TextAlign.right); - } else { - left = anchor.dx - size.width / 2; - top = dir.dy < 0 ? anchor.dy - size.height : anchor.dy; - aligns.add(TextAlign.center); - } - rects.add( - Rect.fromLTWH( - left.clamp(0.0, math.max(0.0, boxW - size.width)), - top.clamp(0.0, math.max(0.0, boxH - size.height)), - size.width, - size.height, - ), - ); - } - return ( - chartSide: chartSide, - rects: rects, - aligns: aligns, - maxLines: maxLines, - ); - } - - Widget _radarAxisLabel({ - required String label, - required int index, - required int count, - required ({ - double chartSide, - List rects, - List aligns, - int maxLines, - }) - layout, - required Color textColor, - }) { - final rect = layout.rects[index]; - return Positioned( - key: ValueKey('radar-axis-label-$index'), - left: rect.left, - top: rect.top, - width: rect.width, - height: rect.height, - child: Text( - label, - maxLines: layout.maxLines, - overflow: TextOverflow.ellipsis, - textAlign: layout.aligns[index], - style: _radarLabelStyle(count, textColor), - ), - ); - } - - /// Extract the touched real-series vertex from a radar touch response, - /// ignoring the invisible scale anchor dataset. - ({int series, int entry, double value, Offset offset})? _radarSpotFrom( - RadarTouchResponse? response, - ChartSpec spec, - ) { - final spot = response?.touchedSpot; - if (spot == null) return null; - if (spot.touchedDataSetIndex < 0 || - spot.touchedDataSetIndex >= spec.series.length) { - return null; // the anchor dataset, or out of range - } - return ( - series: spot.touchedDataSetIndex, - entry: spot.touchedRadarEntryIndex, - value: spot.touchedRadarEntry.value, - offset: spot.offset, - ); - } - - /// A small floating tooltip for the hovered radar vertex, like the other - /// charts: the axis label, the series name and the value. - Widget _radarTooltip( - ChartSpec spec, - double side, - ({int series, int entry, double value, Offset offset}) touch, - ) { - final axis = touch.entry >= 0 && touch.entry < spec.x.length - ? spec.x[touch.entry] - : ''; - final series = touch.series < spec.series.length - ? spec.series[touch.series].name - : ''; - final label = series.isEmpty ? 'Reeks ${touch.series + 1}' : series; - final onLeftHalf = touch.offset.dx <= side / 2; - return Positioned( - left: onLeftHalf ? (touch.offset.dx + w * 0.012) : null, - right: onLeftHalf ? null : (side - touch.offset.dx + w * 0.012), - top: (touch.offset.dy - w * 0.03).clamp(0.0, math.max(0.0, side - 1)), - child: IgnorePointer( - child: ConstrainedBox( - constraints: BoxConstraints(maxWidth: side * 0.6), - child: Container( - padding: EdgeInsets.symmetric( - horizontal: w * 0.012, - vertical: w * 0.006, - ), - decoration: BoxDecoration( - color: const Color(0xFF0F172A), - borderRadius: BorderRadius.circular(w * 0.008), - boxShadow: const [ - BoxShadow(color: Color(0x33000000), blurRadius: 6), - ], - ), - child: Text( - '${axis.isEmpty ? '' : '$axis\n'}$label: ${_fmtNum(touch.value)}', - maxLines: 3, - overflow: TextOverflow.ellipsis, - style: _tooltipStyle(), - ), - ), - ), - ), - ); - } - - /// Vertical scale legend shown to the right of a radar chart: the tick values - /// from the outer ring (top) down to the centre (bottom), in a small font. - Widget _radarScaleLegend( - ({double lo, double hi, int ticks}) scale, - Color textColor, - ) { - final style = _applyFont( - font, - TextStyle( - fontSize: w * 0.012 * _labelScale, - color: textColor.withValues(alpha: 0.62), - fontWeight: FontWeight.w600, - ), - ); - final tickColor = textColor.withValues(alpha: 0.3); - return Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - for (var k = scale.ticks; k >= 0; k--) ...[ - if (k != scale.ticks) SizedBox(height: w * 0.018 * _labelScale), - Row( - mainAxisSize: MainAxisSize.min, - children: [ - Container(width: w * 0.012, height: 1, color: tickColor), - SizedBox(width: w * 0.006), - Flexible( - child: Text( - _fmtNum(scale.lo + (scale.hi - scale.lo) * k / scale.ticks), - style: style, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), - ], - ), - ], - ], - ); - } - - /// Resolves the radar scale: a low/high pair plus an even tick count. Honours - /// the optional [ChartSpec.minBound]/[maxBound] and otherwise rounds the data - /// range to a tidy scale so the rings read as round numbers. - ({double lo, double hi, int ticks}) radarScale(ChartSpec spec) { - var dataMin = 0.0; - var dataMax = 0.0; - var seen = false; - for (final s in spec.series) { - for (final v in s.data) { - if (!seen) { - dataMin = v; - dataMax = v; - seen = true; - } else { - if (v < dataMin) dataMin = v; - if (v > dataMax) dataMax = v; - } - } - } - if (!seen) { - dataMin = 0; - dataMax = 1; - } - final rawLo = spec.minBound ?? (dataMin < 0 ? dataMin : 0); - final rawHi = spec.maxBound ?? dataMax; - final nice = _niceScale(rawLo, rawHi); - final lo = spec.minBound ?? nice.lo; - var hi = spec.maxBound ?? nice.hi; - if (hi <= lo) hi = lo + nice.step; - final ticks = math.max(2, ((hi - lo) / nice.step).round()); - return (lo: lo, hi: hi, ticks: ticks); - } - - ({double lo, double hi, double step}) _niceScale(double lo, double hi) { - final range = (hi - lo).abs(); - final r = range <= 0 ? 1.0 : range; - final rawStep = r / 4; - final mag = math - .pow(10, (math.log(rawStep) / math.ln10).floor()) - .toDouble(); - final norm = rawStep / mag; - final niceNorm = norm < 1.5 - ? 1.0 - : norm < 3 - ? 2.0 - : norm < 7 - ? 5.0 - : 10.0; - final step = niceNorm * mag; - return ( - lo: (lo / step).floor() * step, - hi: (hi / step).ceil() * step, - step: step, - ); - } - - TextStyle _tooltipStyle() => _applyFont( - font, - TextStyle( - color: Colors.white, - fontSize: (w * 0.013 * _labelScale).clamp(11, 18), - height: 1.25, - fontWeight: FontWeight.w700, - ), - ); - - /// Tooltip style for line charts. Each touched dot adds two lines, so when - /// several dots overlap the font shrinks a step to keep the stack readable. - TextStyle _lineTooltipStyle(int count) { - final base = (w * 0.013 * _labelScale).clamp(11.0, 18.0); - final shrink = (1 - (count - 2) * 0.13).clamp(0.6, 1.0); - return _applyFont( - font, - TextStyle( - color: Colors.white, - fontSize: (base * shrink).clamp(8.0, 18.0), - height: 1.2, - fontWeight: FontWeight.w700, - ), - ); - } - - Widget _placeholder(BuildContext context) => - _placeholderText(context.l10n.d('Geen grafiekgegevens')); - - Widget _placeholderText(String text) => Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.bar_chart_outlined, - size: w * 0.08, - color: const Color(0xFF94A3B8), - ), - SizedBox(height: w * 0.01), - Text( - text, - style: TextStyle(color: const Color(0xFF94A3B8), fontSize: w * 0.02), - ), - ], - ), - ); -} - -class _HoverPieChart extends StatefulWidget { - final List values; - final List labels; - final List colors; - final double radius; - final double centerSpaceRadius; - final double sectionSpace; - final TextStyle titleStyle; - final TextStyle tooltipStyle; - - /// Slice index highlighted from outside (e.g. hovering the legend), combined - /// with this chart's own touch hover. - final int? externalHover; - - const _HoverPieChart({ - required this.values, - required this.labels, - required this.colors, - required this.radius, - required this.centerSpaceRadius, - required this.sectionSpace, - required this.titleStyle, - required this.tooltipStyle, - this.externalHover, - }); - - @override - State<_HoverPieChart> createState() => _HoverPieChartState(); -} - -class _HoverPieChartState extends State<_HoverPieChart> { - int? _hovered; - - @override - Widget build(BuildContext context) { - final total = widget.values.fold(0, (a, b) => a + b); - final external = widget.externalHover; - final hovered = - _hovered ?? - (external != null && external >= 0 && external < widget.values.length - ? external - : null); - return Stack( - clipBehavior: Clip.none, - children: [ - Positioned.fill( - child: PieChart( - PieChartData( - sections: [ - for (var i = 0; i < widget.values.length; i++) - PieChartSectionData( - value: widget.values[i], - color: widget.colors[i], - title: widget.values[i] / total >= 0.08 - ? '${(widget.values[i] / total * 100).round()}%' - : '', - radius: widget.radius * (hovered == i ? 1.08 : 1), - titleStyle: widget.titleStyle, - ), - ], - sectionsSpace: widget.sectionSpace, - centerSpaceRadius: widget.centerSpaceRadius, - pieTouchData: PieTouchData( - enabled: true, - mouseCursorResolver: (event, response) => - response?.touchedSection == null - ? SystemMouseCursors.basic - : SystemMouseCursors.click, - touchCallback: (event, response) { - final next = event.isInterestedForInteractions - ? response?.touchedSection?.touchedSectionIndex - : null; - if (next != _hovered) setState(() => _hovered = next); - }, - ), - ), - duration: Duration.zero, - ), - ), - if (hovered != null && hovered >= 0 && hovered < widget.values.length) - Positioned( - top: 4, - left: 4, - right: 4, - child: IgnorePointer( - child: Center( - child: Container( - key: const ValueKey('pie-hover-tooltip'), - padding: const EdgeInsets.symmetric( - horizontal: 10, - vertical: 6, - ), - decoration: BoxDecoration( - color: const Color(0xFF0F172A), - borderRadius: BorderRadius.circular(8), - boxShadow: const [ - BoxShadow(color: Color(0x33000000), blurRadius: 6), - ], - ), - child: Text( - '${widget.labels[hovered]}: ${_formatChartValue(widget.values[hovered])}', - maxLines: 2, - overflow: TextOverflow.ellipsis, - textAlign: TextAlign.center, - style: widget.tooltipStyle, - ), - ), - ), - ), - ), - ], - ); - } -} - -String _formatChartValue(double value) => value == value.roundToDouble() - ? value.toInt().toString() - : value.toStringAsFixed(1); - -/// Register highlight.js language definitions once, so [HighlightView] can -/// colour any common language without throwing. -bool _highlightReady = false; -void _ensureHighlightLanguages() { - if (_highlightReady) return; - allLanguages.forEach(highlight.registerLanguage); - _highlightReady = true; -} - -// ── Shared helper ───────────────────────────────────────────────────────────── - -/// Rendert een afbeelding met zoomfactor op basis van BoxFit.contain. -/// imageSize = 0 → cover (Marp-standaard, vult frame, snijdt bij) -/// imageSize = 100 → volledige afbeelding zichtbaar (contain, evt. randen) -/// imageSize > 100 → inzoomen: groter dan contain, bijgesneden door ClipRect -/// imageSize < 100 → nog meer uitzoomen: afbeelding kleiner dan contain -Widget _zoomedImage( - BuildContext context, - String imagePath, - String? projectPath, - int imageSize, { - Color bgColor = Colors.black, - Alignment alignment = Alignment.center, -}) { - if (imageSize == 0) { - return _resolvedImage( - context, - imagePath, - projectPath, - ); // BoxFit.cover standaard - } - final scale = imageSize / 100.0; - // Size the image box to `scale` × the available area and let BoxFit.contain - // fit the picture inside it. This produces the same visual result as a - // Transform.scale but without a transform layer, which `RepaintBoundary - // .toImage` (used for exports) captures far more reliably — a scaled - // transform layer would frequently render blank in the exported PNG. - return ClipRect( - child: ColoredBox( - color: bgColor, - child: LayoutBuilder( - builder: (context, constraints) { - final boxW = constraints.maxWidth * scale; - final boxH = constraints.maxHeight * scale; - return Align( - alignment: alignment, - child: SizedBox( - width: boxW, - height: boxH, - // BoxFit.contain: toont de volledige afbeelding zonder bijsnijden - child: _resolvedImage( - context, - imagePath, - projectPath, - fit: BoxFit.contain, - ), - ), - ); - }, - ), - ), - ); -} - -Widget _resolvedImage( - BuildContext context, - String imagePath, - String? projectPath, { - BoxFit fit = BoxFit.cover, -}) { - if (imagePath.isEmpty) return _imagePlaceholder(context); - - final String resolved; - if (imagePath.startsWith('/') || imagePath.contains(':\\')) { - resolved = imagePath; - } else if (projectPath != null) { - resolved = '$projectPath/$imagePath'; - } else { - resolved = imagePath; - } - - return Image.file( - File(resolved), - fit: fit, - width: double.infinity, - height: double.infinity, - // Keep showing the previous frame while the next image decodes. Without - // this the widget paints nothing for a frame on a source change, which - // shows up as a black flash between slides — fatal when recording video. - gaplessPlayback: true, - errorBuilder: (context, error, stackTrace) => _imagePlaceholder(context), - ); -} - -Widget _captionOverlay( - BuildContext context, - String caption, - double w, { - double? right, - double? bottom, -}) { - final text = caption.trim(); - if (text.isEmpty) return const SizedBox.shrink(); - // Een copyright/bijschrift staat rechtsonder; als daar een TLP-markering - // staat, schuift het bijschrift erboven zodat het niet wordt overschreven. - final lift = _SlideLinkScope.hasBottomTlpOf(context) - ? _tlpVerticalReserve(w) - : 0.0; - return Positioned( - right: right ?? w * _kTlpEdge, - bottom: (bottom ?? _tlpBottomInset(w)) + lift, - child: Container( - constraints: BoxConstraints(maxWidth: w * 0.5), - padding: EdgeInsets.symmetric(horizontal: w * 0.008, vertical: w * 0.005), - decoration: BoxDecoration( - color: Colors.black.withValues(alpha: 0.58), - borderRadius: BorderRadius.circular(3), - ), - child: Text( - text, - textAlign: TextAlign.right, - style: TextStyle( - color: Colors.white, - fontSize: w * 0.011, - height: 1.25, - ), - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - ), - ); -} - String? _resolvePath(String path, String? projectPath) => resolveSlideAssetPath(path, projectPath); @@ -4453,70 +395,6 @@ String? resolveSlideAssetPath(String path, String? projectPath) { return path; } -// ── TLP-markering: maten gedeeld door de badge en de footer-uitsparing ────── -const double _kTlpFont = 0.018; // × slidebreedte -const double _kTlpEdge = 0.025; // afstand tot de slidehoek (× breedte) -const double _kTlpHPad = 0.011; -const double _kTlpVPad = 0.005; - -double _tlpBottomInset(double w) => w * 0.022; - -/// Geschatte breedte van de TLP-badge, zodat de footer ervoor kan uitwijken. -double _tlpBadgeWidth(double w, TlpLevel tlp) => - tlp.label.length * w * _kTlpFont * 0.62 + 2 * (w * _kTlpHPad); - -/// Verticale ruimte die een TLP-badge rechtsonder inneemt (voor bijschriften). -double _tlpVerticalReserve(double w) => - w * _kTlpFont + 2 * (w * _kTlpVPad) + _tlpBottomInset(w); - -/// Officiële TLP 2.0-markering (FIRST): de gekleurde label op een zwart vlak, -/// rechtsonder. Wijkt uit naar linksonder als het logo rechtsonder staat. -class _TlpOverlay extends StatelessWidget { - final TlpLevel tlp; - final double w; - final ThemeProfile profile; - final bool hasLogo; - - const _TlpOverlay({ - required this.tlp, - required this.w, - required this.profile, - required this.hasLogo, - }); - - @override - Widget build(BuildContext context) { - final toLeft = hasLogo && profile.logoPosition == 'bottom-right'; - return Positioned( - bottom: _tlpBottomInset(w), - left: toLeft ? w * _kTlpEdge : null, - right: toLeft ? null : w * _kTlpEdge, - child: Container( - padding: EdgeInsets.symmetric( - horizontal: w * _kTlpHPad, - vertical: w * _kTlpVPad, - ), - decoration: BoxDecoration( - color: Colors.black, - borderRadius: BorderRadius.circular(w * 0.005), - ), - child: Text( - tlp.label, - style: TextStyle( - color: Color(tlp.foreground), - fontSize: w * _kTlpFont, - fontWeight: FontWeight.w700, - letterSpacing: 0.4, - fontFamily: 'monospace', - fontFamilyFallback: const ['Menlo', 'Consolas', 'Courier New'], - height: 1.0, - ), - ), - ), - ); - } -} - /// Footer onderaan een slide: vrije tekst (links) + paginanummers (rechts), /// op basis van het stijlprofiel. Verborgen op titel-/sectieslides (daar is /// een footer ongebruikelijk en valt 'ie weg tegen de donkere achtergrond). @@ -4546,199 +424,3 @@ double _contentLeftInset(Slide slide, double w) { return w * 0.04; } } - -class _FooterOverlay extends StatelessWidget { - final Slide slide; - final double w; - final ThemeProfile profile; - final int? slideNumber; - final int? slideCount; - final TlpLevel tlp; - - const _FooterOverlay({ - required this.slide, - required this.w, - required this.profile, - this.slideNumber, - this.slideCount, - this.tlp = TlpLevel.none, - }); - - String _applyTokens(String s) { - final now = DateTime.now(); - String two(int v) => v.toString().padLeft(2, '0'); - final date = '${two(now.day)}-${two(now.month)}-${now.year}'; - return s - .replaceAll('{page}', slideNumber?.toString() ?? '') - .replaceAll('{total}', slideCount?.toString() ?? '') - .replaceAll('{date}', date) - .replaceAll('{title}', slide.title); - } - - @override - Widget build(BuildContext context) { - if (!slide.showFooter) return const SizedBox.shrink(); - if (slide.type == SlideType.title || slide.type == SlideType.section) { - return const SizedBox.shrink(); - } - - final footerText = _applyTokens(profile.footerText).trim(); - final showPages = profile.footerShowPageNumbers && slideNumber != null; - if (footerText.isEmpty && !showPages) return const SizedBox.shrink(); - - // Iets kleiner dan voorheen, zodat 'ie niet tegen het logo aan drukt. - final fontSize = w * 0.0145; - final style = TextStyle( - color: _hexColor(profile.textColor).withValues(alpha: 0.7), - fontSize: fontSize, - // Lichte schaduw zodat de footer ook over een afbeelding leesbaar blijft. - shadows: [ - Shadow( - color: Colors.white.withValues(alpha: 0.5), - blurRadius: w * 0.003, - ), - ], - ); - - // Onderhoeken vrijhouden: de footer wijkt horizontaal uit voor het logo en - // de TLP-badge, zodat ze netjes uitlijnen i.p.v. over elkaar te vallen. - double mx(double a, double b) => a > b ? a : b; - final hasLogo = profile.logoPath?.isNotEmpty == true && slide.showLogo; - final logoBottom = hasLogo && profile.logoPosition.startsWith('bottom'); - final logoOnLeft = profile.logoPosition.endsWith('left'); - final logoSpan = w * (profile.logoSize / 1280) * 1.28 + w * 0.012; - final logoLeftEdge = w * (profile.logoSize / 1280) * 0.28; - final tlpOnRight = !(hasLogo && profile.logoPosition == 'bottom-right'); - final tlpSpan = tlp == TlpLevel.none - ? 0.0 - : w * _kTlpEdge + _tlpBadgeWidth(w, tlp) + w * 0.012; - final footerLeftAligned = profile.footerPosition == 'left'; - - // Links uitgelijnd begint de footer waar het logo of de bullets beginnen, - // voor een consistente linkermarge. Anders de standaardmarge. - var left = footerLeftAligned - ? (logoBottom && logoOnLeft - ? logoLeftEdge - : _contentLeftInset(slide, w)) - : w * 0.04; - var right = w * 0.04; - if (logoBottom) { - if (logoOnLeft) { - // Een links-uitgelijnde footer mag bewust met de logo-linkerkant - // uitlijnen; anders schuift 'ie rechts van het logo om overlap te - // voorkomen. - if (!footerLeftAligned) left = mx(left, logoSpan); - } else { - right = mx(right, logoSpan); - } - } - if (tlp != TlpLevel.none) { - if (tlpOnRight) { - right = mx(right, tlpSpan); - } else { - left = mx(left, tlpSpan); - } - } - - final alignment = switch (profile.footerPosition) { - 'left' => Alignment.centerLeft, - 'center' => Alignment.center, - _ => Alignment.centerRight, - }; - final textAlign = switch (profile.footerPosition) { - 'left' => TextAlign.left, - 'center' => TextAlign.center, - _ => TextAlign.right, - }; - - return Positioned( - left: left, - right: right, - bottom: w * 0.02, - child: Align( - alignment: alignment, - child: ConstrainedBox( - constraints: BoxConstraints(maxWidth: w - left - right), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (footerText.isNotEmpty) - Flexible( - child: Text( - footerText, - style: style, - textAlign: textAlign, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), - if (footerText.isNotEmpty && showPages) SizedBox(width: w * 0.02), - if (showPages) - Text( - '$slideNumber / ${slideCount ?? slideNumber}', - style: style, - ), - ], - ), - ), - ), - ); - } -} - -Widget _mediaPlaceholder(IconData icon, String label) { - return Container( - color: const Color(0xFFE2E8F0), - child: Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(icon, color: const Color(0xFF94A3B8), size: 32), - const SizedBox(height: 6), - Text( - label, - style: const TextStyle(color: Color(0xFF94A3B8), fontSize: 12), - ), - ], - ), - ), - ); -} - -Widget _imagePlaceholder(BuildContext context) { - return ColoredBox( - color: const Color(0xFFE2E8F0), - child: LayoutBuilder( - builder: (context, constraints) { - final shortestSide = constraints.biggest.shortestSide; - if (shortestSide < 48) { - return Center( - child: Icon( - Icons.image_outlined, - color: const Color(0xFF94A3B8), - size: shortestSide * 0.65, - ), - ); - } - - return Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon( - Icons.image_outlined, - color: Color(0xFF94A3B8), - size: 24, - ), - const SizedBox(height: 4), - Text( - context.l10n.d('Afbeelding'), - style: const TextStyle(color: Color(0xFF94A3B8), fontSize: 10), - ), - ], - ), - ); - }, - ), - ); -} diff --git a/test/add_slide_dialog_test.dart b/test/add_slide_dialog_test.dart index 1171673..85eb1a2 100644 --- a/test/add_slide_dialog_test.dart +++ b/test/add_slide_dialog_test.dart @@ -57,7 +57,8 @@ void main() { home: Builder( builder: (context) => Center( child: ElevatedButton( - onPressed: () async => picked = await AddSlideDialog.show(context), + onPressed: () async => + picked = await AddSlideDialog.show(context), child: const Text('open'), ), ), diff --git a/test/chart_test.dart b/test/chart_test.dart index d3b5702..dd17294 100644 --- a/test/chart_test.dart +++ b/test/chart_test.dart @@ -108,7 +108,9 @@ void main() { const spec = ChartSpec( type: ChartType.line, x: ['Q1'], - series: [ChartSeries(name: 'A', data: [10])], + series: [ + ChartSeries(name: 'A', data: [10]), + ], minBound: 5, maxBound: 20, ); @@ -121,7 +123,9 @@ void main() { const spec = ChartSpec( type: ChartType.pie, x: ['Q1'], - series: [ChartSeries(name: 'A', data: [10])], + series: [ + ChartSeries(name: 'A', data: [10]), + ], minBound: 5, maxBound: 20, ); @@ -149,7 +153,9 @@ void main() { const spec = ChartSpec( type: ChartType.radar, x: ['A', 'B', 'C'], - series: [ChartSeries(name: 'A', data: [1, 2, 3])], + series: [ + ChartSeries(name: 'A', data: [1, 2, 3]), + ], minBound: 1, maxBound: 5, ); diff --git a/test/code_preview_test.dart b/test/code_preview_test.dart index 6cb91d3..09b63b5 100644 --- a/test/code_preview_test.dart +++ b/test/code_preview_test.dart @@ -45,19 +45,20 @@ void main() { expect(tester.takeException(), isNull); }); - testWidgets('syntax highlighting on uses HighlightView for a known language', ( - tester, - ) async { - final slide = Slide.create( - SlideType.code, - ).copyWith(codeLanguage: 'dart', customMarkdown: 'void main() {}'); - const profile = ThemeProfile(codeHighlightSyntax: true); + testWidgets( + 'syntax highlighting on uses HighlightView for a known language', + (tester) async { + final slide = Slide.create( + SlideType.code, + ).copyWith(codeLanguage: 'dart', customMarkdown: 'void main() {}'); + const profile = ThemeProfile(codeHighlightSyntax: true); - await tester.pumpWidget(_host(slide, profile)); - await tester.pump(); + await tester.pumpWidget(_host(slide, profile)); + await tester.pump(); - expect(find.byType(HighlightView), findsOneWidget); - }); + expect(find.byType(HighlightView), findsOneWidget); + }, + ); testWidgets('syntax highlighting off renders monochrome (CRT) text', ( tester, diff --git a/test/deck_provider_test.dart b/test/deck_provider_test.dart index 9c0e7c9..473d170 100644 --- a/test/deck_provider_test.dart +++ b/test/deck_provider_test.dart @@ -166,37 +166,39 @@ void main() { expect(n.state.revision, greaterThan(revisionBefore)); }); - test('clearAllChecklists is a single undoable step that restores the checks', () { - final n = _notifier()..newDeck('D'); - final slide = Slide.create(SlideType.bullets).copyWith( - listStyle: ListStyle.checklist, - bullets: ['[x] Klaar', '[ ] Open'], - bullets2: ['[x] Tweede'], - ); - n.loadDeck(n.state.deck!.copyWith(slides: [slide])); - expect(n.checkedChecklistCount, 2); + test( + 'clearAllChecklists is a single undoable step that restores the checks', + () { + final n = _notifier()..newDeck('D'); + final slide = Slide.create(SlideType.bullets).copyWith( + listStyle: ListStyle.checklist, + bullets: ['[x] Klaar', '[ ] Open'], + bullets2: ['[x] Tweede'], + ); + n.loadDeck(n.state.deck!.copyWith(slides: [slide])); + expect(n.checkedChecklistCount, 2); - n.clearAllChecklists(); - expect(n.checkedChecklistCount, 0); - expect(n.state.canUndo, isTrue); - final revisionAfterClear = n.state.revision; + n.clearAllChecklists(); + expect(n.checkedChecklistCount, 0); + expect(n.state.canUndo, isTrue); + final revisionAfterClear = n.state.revision; - n.undo(); + n.undo(); - // One undo restores every checked item in both columns... - expect(n.checkedChecklistCount, 2); - expect(n.state.deck!.slides.first.bullets, ['[x] Klaar', '[ ] Open']); - expect(n.state.deck!.slides.first.bullets2, ['[x] Tweede']); - // ...and bumps the revision again so the open editor reflects the restore. - expect(n.state.revision, greaterThan(revisionAfterClear)); - }); + // One undo restores every checked item in both columns... + expect(n.checkedChecklistCount, 2); + expect(n.state.deck!.slides.first.bullets, ['[x] Klaar', '[ ] Open']); + expect(n.state.deck!.slides.first.bullets2, ['[x] Tweede']); + // ...and bumps the revision again so the open editor reflects the restore. + expect(n.state.revision, greaterThan(revisionAfterClear)); + }, + ); test('clearAllChecklists is a no-op when nothing is checked', () { final n = _notifier()..newDeck('D'); - final slide = Slide.create(SlideType.bullets).copyWith( - listStyle: ListStyle.checklist, - bullets: ['[ ] Open'], - ); + final slide = Slide.create( + SlideType.bullets, + ).copyWith(listStyle: ListStyle.checklist, bullets: ['[ ] Open']); n.loadDeck(n.state.deck!.copyWith(slides: [slide])); expect(n.state.canUndo, isFalse); @@ -472,4 +474,31 @@ void main() { n.undo(); // één stap terug herstelt de hele vervanging expect(n.state.deck!.slides.first.title, 'Hallo wereld'); }); + + test('find and replace covers the second column and both column titles', () { + final n = _notifier(); + n.loadDeck( + Deck( + title: 'D', + slides: [ + Slide.create(SlideType.twoBullets).copyWith( + title: 'foo title', + columnTitle1: 'foo left', + columnTitle2: 'foo right', + bullets: ['foo a'], + bullets2: ['foo b', 'foo c'], + ), + ], + ), + ); + + // title + columnTitle1 + columnTitle2 + bullets(1) + bullets2(2) = 6 + expect(n.countMatches('foo'), 6); + expect(n.replaceAll('foo', 'bar'), 6); + + final s = n.state.deck!.slides.first; + expect(s.columnTitle1, 'bar left'); + expect(s.columnTitle2, 'bar right'); + expect(s.bullets2, ['bar b', 'bar c']); + }); } diff --git a/test/file_service_test.dart b/test/file_service_test.dart index 68ed830..679906f 100644 --- a/test/file_service_test.dart +++ b/test/file_service_test.dart @@ -1,5 +1,7 @@ +import 'dart:convert'; import 'dart:io'; +import 'package:archive/archive.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:ocideck/models/deck.dart'; import 'package:ocideck/models/settings.dart'; @@ -62,4 +64,67 @@ void main() { expect(service.currentThemeProfile.logoPath, logo.path); }, ); + + test( + 'importPackageBytes ignores path-traversal entries (zip slip)', + () async { + final temp = await Directory.systemTemp.createTemp('ocideck_zipslip_'); + addTearDown(() async { + if (await temp.exists()) await temp.delete(recursive: true); + }); + + final archive = Archive(); + final md = utf8.encode('---\nmarp: true\n---\n# Hi'); + archive.addFile(ArchiveFile('deck.md', md.length, md)); + final evil = utf8.encode('pwned'); + archive.addFile(ArchiveFile('../evil.txt', evil.length, evil)); + final zipBytes = ZipEncoder().encode(archive); + + final service = FileService( + MarkdownService(), + ImageService(), + () => const ThemeProfile(), + ); + final mdPath = await service.importPackageBytes(zipBytes, temp.path); + + // The traversal entry must not have escaped the extraction folder. + expect(await File(p.join(temp.path, 'evil.txt')).exists(), isFalse); + // The legitimate markdown landed inside it. + expect(mdPath, isNotNull); + expect(p.isWithin(temp.path, mdPath!), isTrue); + expect(await File(mdPath).exists(), isTrue); + }, + ); + + test( + 'importFromUrl refuses non-web schemes and private/loopback hosts', + () async { + final temp = await Directory.systemTemp.createTemp('ocideck_ssrf_'); + addTearDown(() async { + if (await temp.exists()) await temp.delete(recursive: true); + }); + final service = FileService( + MarkdownService(), + ImageService(), + () => const ThemeProfile(), + ); + + // These are all rejected before any network access happens. + for (final url in [ + 'ftp://example.com/x', // non-web scheme + 'file:///etc/passwd', // non-web scheme + 'http://localhost:8080/x.ocideck', // loopback name + 'http://127.0.0.1/x', // loopback IP + 'http://192.168.1.5/x', // private IP + 'http://10.0.0.9/x', // private IP + 'http://169.254.1.1/x', // link-local IP + ]) { + expect( + await service.importFromUrl(url, temp.path), + isNull, + reason: 'should refuse $url', + ); + } + }, + ); } diff --git a/test/image_dedup_service_test.dart b/test/image_dedup_service_test.dart index 17cfe29..92e56f2 100644 --- a/test/image_dedup_service_test.dart +++ b/test/image_dedup_service_test.dart @@ -68,10 +68,10 @@ void main() { final a = write('a.png', [1]); final b = write('b.png', [1]); - final keeper = service.chooseKeeper( - [a, b], - usageCountOf: (path) => path == b ? 2 : 0, - ); + final keeper = service.chooseKeeper([ + a, + b, + ], usageCountOf: (path) => path == b ? 2 : 0); expect(keeper, b); }); @@ -79,9 +79,9 @@ void main() { test('falls back to the oldest file when usages are equal', () { final newer = write('newer.png', [1]); final older = write('older.png', [1]); - File(older).setLastModifiedSync( - DateTime.now().subtract(const Duration(days: 7)), - ); + File( + older, + ).setLastModifiedSync(DateTime.now().subtract(const Duration(days: 7))); expect(service.chooseKeeper([newer, older]), older); }); diff --git a/test/marp_html_service_test.dart b/test/marp_html_service_test.dart index 319b8c4..1941acb 100644 --- a/test/marp_html_service_test.dart +++ b/test/marp_html_service_test.dart @@ -77,6 +77,25 @@ void main() {} expect(html, contains(r'<\/script')); }); + test('build() neutralises a mixed-case closing-script breakout', () async { + final service = MarpHtmlService(loadAsset: _diskLoader); + final html = await service.build('# X\n\nfoo bar'); + // Case tricks must not slip past the guard. + expect(html, isNot(contains(''))); + expect(html, contains(r'<\/ScRiPt')); + }); + + test( + 'build() bundles DOMPurify and sanitises the rendered markdown', + () async { + final service = MarpHtmlService(loadAsset: _diskLoader); + final html = await service.build('# X'); + // The sanitiser is inlined and actually used before content hits the DOM. + expect(html, contains('DOMPurify')); + expect(html, contains('DOMPurify.sanitize(')); + }, + ); + test('a theme colours the slides with the profile palette', () async { final service = MarpHtmlService( loadAsset: _diskLoader, @@ -108,7 +127,10 @@ void main() {} codeTextColor: '#33FF33', codeFontFamily: 'Courier New', ); - final html = await service.build('```dart\nvoid main() {}\n```', theme: theme); + final html = await service.build( + '```dart\nvoid main() {}\n```', + theme: theme, + ); expect(html, contains('.slide pre{background:#000000;color:#33FF33')); expect(html, contains('.slide pre code{color:#33FF33')); diff --git a/test/table_clipboard_test.dart b/test/table_clipboard_test.dart index dd4b9ff..a3d5b37 100644 --- a/test/table_clipboard_test.dart +++ b/test/table_clipboard_test.dart @@ -61,13 +61,10 @@ void main() { }); test('parses a markdown table and drops the separator row', () { - expect( - parseClipboardTable('| Naam | Score |\n|---|---:|\n| Jan | 8 |'), - [ - ['Naam', 'Score'], - ['Jan', '8'], - ], - ); + expect(parseClipboardTable('| Naam | Score |\n|---|---:|\n| Jan | 8 |'), [ + ['Naam', 'Score'], + ['Jan', '8'], + ]); }); test('plain text is not a table', () { diff --git a/tool/check_bundled_js.dart b/tool/check_bundled_js.dart new file mode 100644 index 0000000..54d105e --- /dev/null +++ b/tool/check_bundled_js.dart @@ -0,0 +1,178 @@ +// Security gate for the vendored JavaScript bundles that get inlined into the +// offline HTML export (marked, highlight.js, DOMPurify, mermaid, MathJax — see +// lib/services/marp_html_service.dart). Unlike Dart packages, these are checked +// into the repo by hand and are not covered by `flutter pub outdated`, so this +// tool is their dedicated safety net. +// +// dart run tool/check_bundled_js.dart (or: make deps-check) +// +// It does two independent things, both of which can fail the build: +// +// 1. Integrity (offline, deterministic): every file listed in +// assets/web_export/MANIFEST.json must still hash to the recorded sha256. +// Catches a bundle that was swapped, truncated, or edited without the +// manifest (and therefore this review) being updated. +// +// 2. Known vulnerabilities (online): each pinned package@version is queried +// against the OSV database (https://osv.dev). Any advisory fails the gate +// so we learn a vendored bundle needs upgrading the moment a CVE lands — +// not at the next manual audit. +// +// Exit codes: 0 = clean 1 = integrity mismatch or known vulnerability +// 2 = could not run the check (missing manifest / no network) +// +// Run it routinely (it is wired into `make deps-check` and CI). When it flags a +// vulnerability, upgrade the bundle, refresh its version + sha256 in +// MANIFEST.json, and update THIRD_PARTY_NOTICES.md in the same commit. + +import 'dart:convert'; +import 'dart:io'; + +import 'package:crypto/crypto.dart'; + +const _manifestPath = 'assets/web_export/MANIFEST.json'; +const _assetDir = 'assets/web_export'; +const _osvUrl = 'https://api.osv.dev/v1/query'; + +Future main(List args) async { + final offline = args.contains('--offline'); + + final manifestFile = File(_manifestPath); + if (!manifestFile.existsSync()) { + stderr.writeln('No $_manifestPath — cannot check the bundled JS.'); + exit(2); + } + + final manifest = + jsonDecode(manifestFile.readAsStringSync()) as Map; + final ecosystem = (manifest['ecosystem'] as String?) ?? 'npm'; + final bundles = (manifest['bundles'] as List).cast>(); + + stdout.writeln('== OciDeck check: bundled JavaScript =='); + stdout.writeln('Manifest: $_manifestPath (${bundles.length} bundles)\n'); + + final integrityProblems = []; + final vulnProblems = []; + var networkFailed = false; + + for (final b in bundles) { + final file = b['file'] as String; + final npm = b['npm'] as String; + final version = b['version'] as String; + final expected = (b['sha256'] as String).toLowerCase(); + final path = '$_assetDir/$file'; + + // --- 1. Integrity ------------------------------------------------------- + final f = File(path); + String integrity; + if (!f.existsSync()) { + integrity = 'MISSING FILE'; + integrityProblems.add('$file — file not found at $path'); + } else { + final actual = sha256.convert(f.readAsBytesSync()).toString(); + if (actual == expected) { + integrity = 'sha256 ok'; + } else { + integrity = 'SHA256 MISMATCH'; + integrityProblems.add( + '$file — sha256 differs from manifest\n' + ' expected $expected\n' + ' actual $actual', + ); + } + } + + // --- 2. Known vulnerabilities (OSV) ------------------------------------ + String vulnStatus; + if (offline) { + vulnStatus = 'skipped (--offline)'; + } else { + final r = await _queryOsv(ecosystem, npm, version); + if (r == null) { + vulnStatus = 'OSV UNREACHABLE'; + networkFailed = true; + } else if (r.isEmpty) { + vulnStatus = 'no known CVEs'; + } else { + vulnStatus = '${r.length} ADVISORY(IES): ${r.join(', ')}'; + vulnProblems.add('$npm@$version → ${r.join(', ')}'); + } + } + + stdout.writeln(' $npm@$version ($file)'); + stdout.writeln(' integrity : $integrity'); + stdout.writeln(' osv : $vulnStatus'); + } + + stdout.writeln(''); + + if (integrityProblems.isEmpty && vulnProblems.isEmpty && !networkFailed) { + stdout.writeln( + 'OK — all bundles match the manifest and have no known CVEs.', + ); + exit(0); + } + + if (integrityProblems.isNotEmpty) { + stderr.writeln('INTEGRITY — ${integrityProblems.length} problem(s):'); + for (final p in integrityProblems) { + stderr.writeln(' $p'); + } + stderr.writeln( + ' A mismatch means a bundle changed without MANIFEST.json being updated.\n' + ' If the change was intentional, refresh the version + sha256 there.', + ); + } + + if (vulnProblems.isNotEmpty) { + stderr.writeln('\nVULNERABILITIES — ${vulnProblems.length} bundle(s):'); + for (final p in vulnProblems) { + stderr.writeln(' $p'); + } + stderr.writeln( + ' Upgrade the bundle, then update MANIFEST.json + THIRD_PARTY_NOTICES.md.\n' + ' Advisory detail: https://osv.dev/', + ); + } + + if (integrityProblems.isNotEmpty || vulnProblems.isNotEmpty) exit(1); + + // Only network trouble, nothing actually wrong with the bundles. + stderr.writeln( + 'COULD NOT VERIFY CVEs — OSV was unreachable. Integrity passed.\n' + ' Re-run with network access, or `--offline` to check integrity only.', + ); + exit(2); +} + +/// Returns the list of OSV advisory IDs affecting [name]@[version], an empty +/// list if there are none, or null if the database could not be reached. +Future?> _queryOsv( + String ecosystem, + String name, + String version, +) async { + final client = HttpClient()..connectionTimeout = const Duration(seconds: 15); + try { + final req = await client.postUrl(Uri.parse(_osvUrl)); + req.headers.contentType = ContentType.json; + req.write( + jsonEncode({ + 'package': {'name': name, 'ecosystem': ecosystem}, + 'version': version, + }), + ); + final resp = await req.close().timeout(const Duration(seconds: 20)); + if (resp.statusCode != 200) return null; + final body = await resp.transform(utf8.decoder).join(); + final data = jsonDecode(body) as Map; + final vulns = (data['vulns'] as List?) ?? const []; + return vulns + .map((v) => (v as Map)['id'] as String) + .toList(); + } on Object { + return null; + } finally { + client.close(force: true); + } +}