@openpresentation/opf 0.14.0 → 0.15.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (348) hide show
  1. package/README.md +24 -38
  2. package/dist/catalog-refs-BSfGELpt.d.ts +128 -0
  3. package/dist/catalog-schemas-CinPh5WA.d.ts +12 -0
  4. package/dist/catalog.d.ts +91 -0
  5. package/dist/{chunk-XOZ4KMSR.js → catalog.js} +16514 -8800
  6. package/dist/{chart-data-B_3z9VTb.d.ts → chart-data-BnNqgnC-.d.ts} +1 -1
  7. package/dist/{chunk-LS2FMPI5.js → chunk-2CJPC4HI.js} +760 -851
  8. package/dist/{chunk-4VJDTF55.js → chunk-374R35TJ.js} +1 -1
  9. package/dist/{chunk-UPQQD7MA.js → chunk-3JG4YXWF.js} +7 -5
  10. package/dist/{chunk-4LDGKLUC.js → chunk-3RKB4RDJ.js} +1 -1
  11. package/dist/{chunk-BFLVSVA7.js → chunk-5FTUIDUH.js} +1 -1
  12. package/dist/{chunk-D3TY4TT6.js → chunk-HXHOZ7AH.js} +1407 -154
  13. package/dist/{chunk-6OXY2CP3.js → chunk-O744SXXA.js} +319 -162
  14. package/dist/{chunk-JDJBDYSR.js → chunk-QDI75P2M.js} +194 -846
  15. package/dist/chunk-SPFV27O5.js +210 -0
  16. package/dist/chunk-TRDXRRQK.js +17 -0
  17. package/dist/{chunk-Z65B2B77.js → chunk-TWQJLI6E.js} +2 -2
  18. package/dist/{chunk-Q2TCMN56.js → chunk-U4J75TRG.js} +1 -1
  19. package/dist/{chunk-XIZUK2XM.js → chunk-UBFJ2QHH.js} +5 -3
  20. package/dist/{chunk-CSQHPVGL.js → chunk-VGAOJEY7.js} +1 -1
  21. package/dist/{chunk-5ZU6JAPP.js → chunk-ZPU46TFA.js} +1 -1
  22. package/dist/{composition-_WEwBe2a.d.ts → composition-D2tghM82.d.ts} +233 -108
  23. package/dist/composition.d.ts +3 -2
  24. package/dist/composition.js +2 -2
  25. package/dist/convert.d.ts +14 -17
  26. package/dist/convert.js +36 -39
  27. package/dist/data.d.ts +1 -1
  28. package/dist/data.js +3 -3
  29. package/dist/diff.d.ts +1 -1
  30. package/dist/diff.js +7 -7
  31. package/dist/docs.js +29 -29
  32. package/dist/examples.d.ts +1 -1
  33. package/dist/examples.js +48431 -3697
  34. package/dist/{finding-C_por275.d.ts → finding-CT_f_vbD.d.ts} +2 -2
  35. package/dist/format.js +2 -2
  36. package/dist/index.d.ts +225 -76
  37. package/dist/index.js +341 -258
  38. package/dist/json-BFSdESOE.d.ts +19 -0
  39. package/dist/markdown.d.ts +19 -7
  40. package/dist/markdown.js +9 -9
  41. package/dist/{pagination-BZpXLvFL.d.ts → pagination-wedbe7eq.d.ts} +31 -31
  42. package/dist/pagination.d.ts +4 -5
  43. package/dist/pagination.js +6 -6
  44. package/dist/patch.d.ts +1 -1
  45. package/dist/patch.js +6 -6
  46. package/dist/{presentation-C5eHkevA.d.ts → presentation-CDKQVkyI.d.ts} +382 -550
  47. package/dist/repo-readme.js +1 -1
  48. package/dist/schemas.d.ts +284 -1
  49. package/dist/schemas.js +1 -1
  50. package/dist/spec/README.md +1 -1
  51. package/dist/spec/catalogs/font-schemes/angsana-new.json +1 -1
  52. package/dist/spec/catalogs/font-schemes/aparajita.json +2 -2
  53. package/dist/spec/catalogs/font-schemes/arabic-typesetting.json +1 -1
  54. package/dist/spec/catalogs/font-schemes/batang.json +1 -1
  55. package/dist/spec/catalogs/font-schemes/daunpenh.json +1 -1
  56. package/dist/spec/catalogs/font-schemes/david.json +1 -1
  57. package/dist/spec/catalogs/font-schemes/dilleniaupc.json +1 -1
  58. package/dist/spec/catalogs/font-schemes/fangsong.json +1 -1
  59. package/dist/spec/catalogs/font-schemes/gautami.json +1 -1
  60. package/dist/spec/catalogs/font-schemes/gisha.json +2 -2
  61. package/dist/spec/catalogs/font-schemes/gungsuh.json +1 -1
  62. package/dist/spec/catalogs/font-schemes/index.json +1 -1
  63. package/dist/spec/catalogs/font-schemes/kalinga.json +1 -1
  64. package/dist/spec/catalogs/font-schemes/kartika.json +1 -1
  65. package/dist/spec/catalogs/font-schemes/khmer-ui.json +1 -1
  66. package/dist/spec/catalogs/font-schemes/latha.json +1 -1
  67. package/dist/spec/catalogs/font-schemes/malgun-gothic.json +1 -1
  68. package/dist/spec/catalogs/font-schemes/mangal.json +3 -3
  69. package/dist/spec/catalogs/font-schemes/meiryo.json +1 -1
  70. package/dist/spec/catalogs/font-schemes/microsoft-jhenghei.json +1 -1
  71. package/dist/spec/catalogs/font-schemes/microsoft-yahei.json +4 -4
  72. package/dist/spec/catalogs/font-schemes/mingliu.json +1 -1
  73. package/dist/spec/catalogs/font-schemes/miriam.json +1 -1
  74. package/dist/spec/catalogs/font-schemes/ms-mincho.json +1 -1
  75. package/dist/spec/catalogs/font-schemes/nirmala-ui.json +1 -1
  76. package/dist/spec/catalogs/font-schemes/noto-naksh-arabic.json +1 -1
  77. package/dist/spec/catalogs/font-schemes/noto-nastaliq-urdu.json +2 -2
  78. package/dist/spec/catalogs/font-schemes/noto-sans-arabic.json +3 -3
  79. package/dist/spec/catalogs/font-schemes/noto-sans-armenian.json +1 -1
  80. package/dist/spec/catalogs/font-schemes/noto-sans-bengali.json +2 -2
  81. package/dist/spec/catalogs/font-schemes/noto-sans-devangari.json +2 -2
  82. package/dist/spec/catalogs/font-schemes/noto-sans-ethiopic.json +1 -1
  83. package/dist/spec/catalogs/font-schemes/noto-sans-georgian.json +1 -1
  84. package/dist/spec/catalogs/font-schemes/noto-sans-gujarati.json +1 -1
  85. package/dist/spec/catalogs/font-schemes/noto-sans-gurmukhi.json +1 -1
  86. package/dist/spec/catalogs/font-schemes/noto-sans-hebrew.json +1 -1
  87. package/dist/spec/catalogs/font-schemes/noto-sans-jp.json +1 -1
  88. package/dist/spec/catalogs/font-schemes/noto-sans-kannada.json +1 -1
  89. package/dist/spec/catalogs/font-schemes/noto-sans-khmer.json +1 -1
  90. package/dist/spec/catalogs/font-schemes/noto-sans-kr.json +1 -1
  91. package/dist/spec/catalogs/font-schemes/noto-sans-malayalam.json +1 -1
  92. package/dist/spec/catalogs/font-schemes/noto-sans-mongolian.json +1 -1
  93. package/dist/spec/catalogs/font-schemes/noto-sans-oriya.json +1 -1
  94. package/dist/spec/catalogs/font-schemes/noto-sans-sc.json +4 -4
  95. package/dist/spec/catalogs/font-schemes/noto-sans-tamil.json +1 -1
  96. package/dist/spec/catalogs/font-schemes/noto-sans-tc.json +1 -1
  97. package/dist/spec/catalogs/font-schemes/noto-sans-telugu.json +1 -1
  98. package/dist/spec/catalogs/font-schemes/noto-sans-thai.json +1 -1
  99. package/dist/spec/catalogs/font-schemes/noto-sans.json +4 -4
  100. package/dist/spec/catalogs/font-schemes/nyala.json +1 -1
  101. package/dist/spec/catalogs/font-schemes/pmingliu.json +1 -1
  102. package/dist/spec/catalogs/font-schemes/raavi.json +1 -1
  103. package/dist/spec/catalogs/font-schemes/sakkal-majalla.json +3 -3
  104. package/dist/spec/catalogs/font-schemes/shonar-bangla.json +2 -2
  105. package/dist/spec/catalogs/font-schemes/shruti.json +1 -1
  106. package/dist/spec/catalogs/font-schemes/simsun.json +1 -1
  107. package/dist/spec/catalogs/font-schemes/sylfaen.json +2 -2
  108. package/dist/spec/catalogs/font-schemes/traditional-arabic.json +3 -3
  109. package/dist/spec/catalogs/font-schemes/tunga.json +1 -1
  110. package/dist/spec/catalogs/font-schemes/vrinda.json +1 -1
  111. package/dist/spec/catalogs/font-schemes/yu-gothic.json +1 -1
  112. package/dist/spec/catalogs/layouts/capability-map.json +1 -4
  113. package/dist/spec/catalogs/layouts/chart-1x-title-center.json +30 -0
  114. package/dist/spec/catalogs/layouts/chart-2x-title-center.json +36 -0
  115. package/dist/spec/catalogs/layouts/chart-2x-vertical-title-left.json +37 -0
  116. package/dist/spec/catalogs/layouts/chart-3x-bottom-vertical-title-left.json +46 -0
  117. package/dist/spec/catalogs/layouts/chart-3x-left-title-center.json +45 -0
  118. package/dist/spec/catalogs/layouts/chart-3x-left.json +40 -0
  119. package/dist/spec/catalogs/layouts/chart-3x-right-title-center.json +45 -0
  120. package/dist/spec/catalogs/layouts/chart-3x-right.json +40 -0
  121. package/dist/spec/catalogs/layouts/chart-3x-title-center.json +39 -0
  122. package/dist/spec/catalogs/layouts/chart-3x-top-vertical-title-left.json +46 -0
  123. package/dist/spec/catalogs/layouts/competitive-landscape.json +1 -4
  124. package/dist/spec/catalogs/layouts/customer-journey.json +1 -4
  125. package/dist/spec/catalogs/layouts/decision-tree.json +1 -4
  126. package/dist/spec/catalogs/layouts/ecosystem-map.json +1 -4
  127. package/dist/spec/catalogs/layouts/extract/canonical.json +0 -9
  128. package/dist/spec/catalogs/layouts/geographic-map.json +1 -4
  129. package/dist/spec/catalogs/layouts/icon-grid.json +1 -4
  130. package/dist/spec/catalogs/layouts/image-1x-crop-title-center.json +32 -0
  131. package/dist/spec/catalogs/layouts/image-1x-crop-title-left.json +32 -0
  132. package/dist/spec/catalogs/layouts/image-1x-crop.json +27 -0
  133. package/dist/spec/catalogs/layouts/image-1x-fit-title-center.json +32 -0
  134. package/dist/spec/catalogs/layouts/image-1x.json +3 -0
  135. package/dist/spec/catalogs/layouts/image-2x-crop-title-center.json +38 -0
  136. package/dist/spec/catalogs/layouts/image-2x-crop-vertical-title-left.json +39 -0
  137. package/dist/spec/catalogs/layouts/image-2x-crop-vertical.json +34 -0
  138. package/dist/spec/catalogs/layouts/image-2x-crop.json +33 -0
  139. package/dist/spec/catalogs/layouts/image-2x-fit-title-center.json +38 -0
  140. package/dist/spec/catalogs/layouts/image-2x-fit-vertical-title-left.json +39 -0
  141. package/dist/spec/catalogs/layouts/image-2x-fit-vertical.json +34 -0
  142. package/dist/spec/catalogs/layouts/image-2x.json +3 -0
  143. package/dist/spec/catalogs/layouts/image-3x-crop-title-center.json +41 -0
  144. package/dist/spec/catalogs/layouts/image-3x-crop-vertical-title-left.json +42 -0
  145. package/dist/spec/catalogs/layouts/image-3x-crop-vertical.json +37 -0
  146. package/dist/spec/catalogs/layouts/image-3x-crop.json +36 -0
  147. package/dist/spec/catalogs/layouts/image-3x-fit-title-center.json +41 -0
  148. package/dist/spec/catalogs/layouts/image-3x-fit-vertical-title-left.json +42 -0
  149. package/dist/spec/catalogs/layouts/image-3x-fit-vertical.json +37 -0
  150. package/dist/spec/catalogs/layouts/image-3x-fit.json +36 -0
  151. package/dist/spec/catalogs/layouts/image-3x.json +3 -0
  152. package/dist/spec/catalogs/layouts/image-focus.json +3 -6
  153. package/dist/spec/catalogs/layouts/image-only-1x-crop-title-center.json +31 -0
  154. package/dist/spec/catalogs/layouts/image-only-1x-fit-title-center.json +31 -0
  155. package/dist/spec/catalogs/layouts/image-only-2x-crop-title-center.json +37 -0
  156. package/dist/spec/catalogs/layouts/image-only-2x-crop-vertical-title-left.json +38 -0
  157. package/dist/spec/catalogs/layouts/image-only-2x-crop-vertical.json +33 -0
  158. package/dist/spec/catalogs/layouts/image-only-2x-fit-title-center.json +37 -0
  159. package/dist/spec/catalogs/layouts/image-only-2x-fit-vertical-title-left.json +38 -0
  160. package/dist/spec/catalogs/layouts/image-only-2x-fit-vertical.json +33 -0
  161. package/dist/spec/catalogs/layouts/image-only-3x-crop-title-center.json +40 -0
  162. package/dist/spec/catalogs/layouts/image-only-3x-crop-vertical-title-left.json +41 -0
  163. package/dist/spec/catalogs/layouts/image-only-3x-crop-vertical.json +36 -0
  164. package/dist/spec/catalogs/layouts/image-only-3x-crop.json +35 -0
  165. package/dist/spec/catalogs/layouts/image-only-3x-fit-title-center.json +40 -0
  166. package/dist/spec/catalogs/layouts/image-only-3x-fit-vertical-title-left.json +41 -0
  167. package/dist/spec/catalogs/layouts/image-only-3x-fit-vertical.json +36 -0
  168. package/dist/spec/catalogs/layouts/image-only-3x-fit.json +35 -0
  169. package/dist/spec/catalogs/layouts/index.json +1041 -151
  170. package/dist/spec/catalogs/layouts/list-1x-box-title-center.json +32 -0
  171. package/dist/spec/catalogs/layouts/list-1x-box-vertical-title-center.json +33 -0
  172. package/dist/spec/catalogs/layouts/list-1x-box-vertical-title-left.json +33 -0
  173. package/dist/spec/catalogs/layouts/list-1x-box-vertical.json +28 -0
  174. package/dist/spec/catalogs/layouts/list-1x-box.json +27 -0
  175. package/dist/spec/catalogs/layouts/list-1x-itemimage-title-center.json +32 -0
  176. package/dist/spec/catalogs/layouts/list-1x-itemimage-vertical-title-center.json +33 -0
  177. package/dist/spec/catalogs/layouts/list-1x-title-center.json +31 -0
  178. package/dist/spec/catalogs/layouts/list-1x-vertical-title-center.json +32 -0
  179. package/dist/spec/catalogs/layouts/list-2x-box-title-center.json +38 -0
  180. package/dist/spec/catalogs/layouts/list-2x-box-vertical-title-center.json +39 -0
  181. package/dist/spec/catalogs/layouts/list-2x-box-vertical-title-left.json +39 -0
  182. package/dist/spec/catalogs/layouts/list-2x-box-vertical.json +34 -0
  183. package/dist/spec/catalogs/layouts/list-2x-box.json +33 -0
  184. package/dist/spec/catalogs/layouts/list-2x-itemimage-title-center.json +38 -0
  185. package/dist/spec/catalogs/layouts/list-2x-itemimage-vertical-title-center.json +39 -0
  186. package/dist/spec/catalogs/layouts/list-2x-itemimage-vertical-title-left.json +39 -0
  187. package/dist/spec/catalogs/layouts/list-2x-itemimage-vertical.json +34 -0
  188. package/dist/spec/catalogs/layouts/list-2x-title-center.json +37 -0
  189. package/dist/spec/catalogs/layouts/list-2x-vertical-title-center.json +38 -0
  190. package/dist/spec/catalogs/layouts/list-2x-vertical-title-left.json +38 -0
  191. package/dist/spec/catalogs/layouts/list-2x-vertical.json +33 -0
  192. package/dist/spec/catalogs/layouts/list-3x-box-title-center.json +41 -0
  193. package/dist/spec/catalogs/layouts/list-3x-box-vertical-title-center.json +42 -0
  194. package/dist/spec/catalogs/layouts/list-3x-box-vertical-title-left.json +42 -0
  195. package/dist/spec/catalogs/layouts/list-3x-box-vertical.json +37 -0
  196. package/dist/spec/catalogs/layouts/list-3x-box.json +36 -0
  197. package/dist/spec/catalogs/layouts/list-3x-itemimage-title-center.json +41 -0
  198. package/dist/spec/catalogs/layouts/list-3x-itemimage-vertical-title-center.json +42 -0
  199. package/dist/spec/catalogs/layouts/list-3x-itemimage-vertical-title-left.json +42 -0
  200. package/dist/spec/catalogs/layouts/list-3x-itemimage-vertical.json +37 -0
  201. package/dist/spec/catalogs/layouts/list-3x-itemimage.json +36 -0
  202. package/dist/spec/catalogs/layouts/list-3x-title-center.json +40 -0
  203. package/dist/spec/catalogs/layouts/list-3x-vertical-title-center.json +41 -0
  204. package/dist/spec/catalogs/layouts/list-3x-vertical-title-left.json +41 -0
  205. package/dist/spec/catalogs/layouts/list-3x-vertical.json +36 -0
  206. package/dist/spec/catalogs/layouts/list-4x-box-title-center.json +45 -0
  207. package/dist/spec/catalogs/layouts/list-4x-box-vertical-title-center.json +45 -0
  208. package/dist/spec/catalogs/layouts/list-4x-box-vertical-title-left.json +45 -0
  209. package/dist/spec/catalogs/layouts/list-4x-box-vertical.json +40 -0
  210. package/dist/spec/catalogs/layouts/list-4x-box.json +40 -0
  211. package/dist/spec/catalogs/layouts/list-4x-itemimage-title-center.json +45 -0
  212. package/dist/spec/catalogs/layouts/list-4x-itemimage-vertical-title-center.json +45 -0
  213. package/dist/spec/catalogs/layouts/list-4x-itemimage-vertical-title-left.json +45 -0
  214. package/dist/spec/catalogs/layouts/list-4x-itemimage-vertical.json +40 -0
  215. package/dist/spec/catalogs/layouts/list-4x-title-center.json +44 -0
  216. package/dist/spec/catalogs/layouts/list-4x-vertical-title-center.json +44 -0
  217. package/dist/spec/catalogs/layouts/list-4x-vertical-title-left.json +44 -0
  218. package/dist/spec/catalogs/layouts/list-4x-vertical.json +39 -0
  219. package/dist/spec/catalogs/layouts/list-5x-box-title-center.json +48 -0
  220. package/dist/spec/catalogs/layouts/list-5x-box-vertical-title-center.json +50 -0
  221. package/dist/spec/catalogs/layouts/list-5x-box-vertical-title-left.json +50 -0
  222. package/dist/spec/catalogs/layouts/list-5x-box-vertical.json +45 -0
  223. package/dist/spec/catalogs/layouts/list-5x-box.json +43 -0
  224. package/dist/spec/catalogs/layouts/list-5x-itemimage-title-center.json +48 -0
  225. package/dist/spec/catalogs/layouts/list-5x-itemimage-vertical-title-center.json +48 -0
  226. package/dist/spec/catalogs/layouts/list-5x-itemimage-vertical-title-left.json +48 -0
  227. package/dist/spec/catalogs/layouts/list-5x-itemimage-vertical.json +43 -0
  228. package/dist/spec/catalogs/layouts/list-5x-title-center.json +47 -0
  229. package/dist/spec/catalogs/layouts/list-5x-vertical-title-center.json +47 -0
  230. package/dist/spec/catalogs/layouts/list-5x-vertical-title-left.json +47 -0
  231. package/dist/spec/catalogs/layouts/list-5x-vertical.json +42 -0
  232. package/dist/spec/catalogs/layouts/list-6x-box-title-center.json +51 -0
  233. package/dist/spec/catalogs/layouts/list-6x-box-vertical-title-center.json +53 -0
  234. package/dist/spec/catalogs/layouts/list-6x-box-vertical-title-left.json +53 -0
  235. package/dist/spec/catalogs/layouts/list-6x-box-vertical.json +48 -0
  236. package/dist/spec/catalogs/layouts/list-6x-box.json +46 -0
  237. package/dist/spec/catalogs/layouts/list-6x-itemimage-title-center.json +51 -0
  238. package/dist/spec/catalogs/layouts/list-6x-itemimage-vertical-title-center.json +51 -0
  239. package/dist/spec/catalogs/layouts/list-6x-itemimage-vertical-title-left.json +51 -0
  240. package/dist/spec/catalogs/layouts/list-6x-itemimage-vertical.json +46 -0
  241. package/dist/spec/catalogs/layouts/list-6x-title-center.json +50 -0
  242. package/dist/spec/catalogs/layouts/list-6x-vertical-title-center.json +50 -0
  243. package/dist/spec/catalogs/layouts/list-6x-vertical-title-left.json +50 -0
  244. package/dist/spec/catalogs/layouts/list-6x-vertical.json +45 -0
  245. package/dist/spec/catalogs/layouts/network-dependency-map.json +1 -4
  246. package/dist/spec/catalogs/layouts/number-1x-box-title-center.json +30 -0
  247. package/dist/spec/catalogs/layouts/number-1x-box.json +25 -0
  248. package/dist/spec/catalogs/layouts/number-1x-title-center.json +29 -0
  249. package/dist/spec/catalogs/layouts/number-2x-box-title-center.json +36 -0
  250. package/dist/spec/catalogs/layouts/number-2x-box.json +31 -0
  251. package/dist/spec/catalogs/layouts/number-2x-title-center.json +35 -0
  252. package/dist/spec/catalogs/layouts/number-3x-box-title-center.json +39 -0
  253. package/dist/spec/catalogs/layouts/number-3x-box.json +34 -0
  254. package/dist/spec/catalogs/layouts/number-3x-title-center.json +38 -0
  255. package/dist/spec/catalogs/layouts/number-4x-box-title-center.json +43 -0
  256. package/dist/spec/catalogs/layouts/number-4x-box.json +38 -0
  257. package/dist/spec/catalogs/layouts/number-4x-title-center.json +42 -0
  258. package/dist/spec/catalogs/layouts/number-5x-box-title-center.json +46 -0
  259. package/dist/spec/catalogs/layouts/number-5x-box.json +41 -0
  260. package/dist/spec/catalogs/layouts/number-5x-title-center.json +45 -0
  261. package/dist/spec/catalogs/layouts/number-6x-box-title-center.json +49 -0
  262. package/dist/spec/catalogs/layouts/number-6x-box.json +44 -0
  263. package/dist/spec/catalogs/layouts/number-6x-title-center.json +48 -0
  264. package/dist/spec/catalogs/layouts/persona-grid.json +1 -4
  265. package/dist/spec/catalogs/layouts/photo-grid.json +1 -4
  266. package/dist/spec/catalogs/layouts/product-feature.json +1 -4
  267. package/dist/spec/catalogs/layouts/screenshot-walkthrough.json +1 -4
  268. package/dist/spec/catalogs/layouts/swimlane-process.json +1 -4
  269. package/dist/spec/catalogs/layouts/team-bio.json +1 -4
  270. package/dist/spec/catalogs/layouts/text-1x-box-title-center.json +31 -0
  271. package/dist/spec/catalogs/layouts/text-1x-box-title-left.json +31 -0
  272. package/dist/spec/catalogs/layouts/text-1x-center-box-title-center.json +31 -0
  273. package/dist/spec/catalogs/layouts/text-1x-center-box-title-left.json +31 -0
  274. package/dist/spec/catalogs/layouts/text-1x-center-box.json +26 -0
  275. package/dist/spec/catalogs/layouts/text-1x-center-title-center.json +30 -0
  276. package/dist/spec/catalogs/layouts/text-1x-center-title-left.json +30 -0
  277. package/dist/spec/catalogs/layouts/text-1x-center.json +25 -0
  278. package/dist/spec/catalogs/layouts/text-1x-left-box.json +26 -0
  279. package/dist/spec/catalogs/layouts/text-1x-title-center.json +30 -0
  280. package/dist/spec/catalogs/layouts/text-2x-box-title-center.json +37 -0
  281. package/dist/spec/catalogs/layouts/text-2x-box-vertical-title-left.json +37 -0
  282. package/dist/spec/catalogs/layouts/text-2x-center-box-title-center.json +37 -0
  283. package/dist/spec/catalogs/layouts/text-2x-center-box-vertical-title-left.json +37 -0
  284. package/dist/spec/catalogs/layouts/text-2x-center-box.json +32 -0
  285. package/dist/spec/catalogs/layouts/text-2x-center-title-center.json +36 -0
  286. package/dist/spec/catalogs/layouts/text-2x-center-vertical-title-left.json +36 -0
  287. package/dist/spec/catalogs/layouts/text-2x-center.json +31 -0
  288. package/dist/spec/catalogs/layouts/text-2x-left-box.json +32 -0
  289. package/dist/spec/catalogs/layouts/text-2x-title-center.json +36 -0
  290. package/dist/spec/catalogs/layouts/text-2x-vertical-title-left.json +36 -0
  291. package/dist/spec/catalogs/layouts/text-3x-box-title-center.json +40 -0
  292. package/dist/spec/catalogs/layouts/text-3x-box-vertical-title-left.json +41 -0
  293. package/dist/spec/catalogs/layouts/text-3x-center-box-title-center.json +40 -0
  294. package/dist/spec/catalogs/layouts/text-3x-center-box-vertical-title-left.json +41 -0
  295. package/dist/spec/catalogs/layouts/text-3x-center-box.json +35 -0
  296. package/dist/spec/catalogs/layouts/text-3x-center-title-center.json +39 -0
  297. package/dist/spec/catalogs/layouts/text-3x-center-vertical-title-left.json +40 -0
  298. package/dist/spec/catalogs/layouts/text-3x-center.json +34 -0
  299. package/dist/spec/catalogs/layouts/text-3x-left-box.json +35 -0
  300. package/dist/spec/catalogs/layouts/text-3x-left.json +34 -0
  301. package/dist/spec/catalogs/layouts/text-3x-title-center.json +39 -0
  302. package/dist/spec/catalogs/layouts/text-3x-vertical-title-left.json +40 -0
  303. package/dist/spec/catalogs/layouts/title-center-box.json +27 -0
  304. package/dist/spec/catalogs/layouts/title-center-image-bottom.json +34 -0
  305. package/dist/spec/catalogs/layouts/title-center-image-top.json +34 -0
  306. package/dist/spec/catalogs/layouts/title-center.json +26 -0
  307. package/dist/spec/catalogs/layouts/title-left-image-bottom.json +34 -0
  308. package/dist/spec/catalogs/layouts/title-left-image-left.json +34 -0
  309. package/dist/spec/catalogs/layouts/title-left-image-right.json +34 -0
  310. package/dist/spec/catalogs/layouts/title-left-image-top.json +34 -0
  311. package/dist/spec/catalogs/manifest.json +18 -18
  312. package/dist/spec/catalogs/narratives/heros-journey.json +1 -1
  313. package/dist/spec/catalogs/narratives/index.json +1 -1
  314. package/dist/spec/openapi.yaml +3 -3
  315. package/dist/spec/reference/engine-defaults.json +22 -30
  316. package/dist/spec/reference/engine-vocabularies.json +1577 -0
  317. package/dist/spec/schemas/audience.schema.json +5 -26
  318. package/dist/spec/schemas/catalog-manifest.schema.json +3 -3
  319. package/dist/spec/schemas/chart-type.schema.json +2 -35
  320. package/dist/spec/schemas/color-scheme.schema.json +3 -24
  321. package/dist/spec/schemas/finding.schema.json +1 -1
  322. package/dist/spec/schemas/font-scheme.schema.json +6 -37
  323. package/dist/spec/schemas/language.schema.json +7 -28
  324. package/dist/spec/schemas/layout.schema.json +56 -51
  325. package/dist/spec/schemas/narrative.schema.json +4 -24
  326. package/dist/spec/schemas/opf.schema.json +399 -368
  327. package/dist/spec/schemas/purpose.schema.json +5 -26
  328. package/dist/spec/schemas/social-platform.schema.json +2 -23
  329. package/dist/spec/schemas/theme.schema.json +9 -37
  330. package/dist/spec/schemas/tone.schema.json +4 -25
  331. package/dist/spec-files.d.ts +1 -1
  332. package/dist/spec-files.js +1 -1
  333. package/dist/types.d.ts +1125 -4
  334. package/dist/{validation-types-zh03LeZy.d.ts → validation-types-BOcY9GTs.d.ts} +7 -7
  335. package/dist/validator.d.ts +15 -12
  336. package/dist/validator.js +5 -5
  337. package/dist/yaml.d.ts +22 -7
  338. package/dist/yaml.js +10 -9
  339. package/package.json +5 -9
  340. package/dist/catalogs-D-yyQWCQ.d.ts +0 -1343
  341. package/dist/catalogs.d.ts +0 -2
  342. package/dist/catalogs.js +0 -2
  343. package/dist/chunk-PIQXLBBJ.js +0 -445
  344. package/dist/chunk-S5W34SIJ.js +0 -1
  345. package/dist/previews.d.ts +0 -40
  346. package/dist/previews.js +0 -1
  347. package/dist/schemas-DDpqAgyS.d.ts +0 -284
  348. package/dist/spec/catalogs/layouts/image-bleed.json +0 -20
package/dist/docs.js CHANGED
@@ -10,7 +10,7 @@ var docsData = Object.freeze([
10
10
  "slug": "catalog-schema-reference",
11
11
  "file": "docs/catalog-schema-reference.md",
12
12
  "title": "OPF Catalog Schema Reference",
13
- "markdown": "# OPF Catalog Schema Reference\n\nCatalog records are reusable presets that OPF documents reference by id. This page summarizes every catalog companion schema in `spec/schemas/`. The top-level presentation schema has its own [schema reference](schema-reference.md), and the report format every tool shares has the [finding schema reference](finding-schema-reference.md).\n\nOPF documents usually reference these records with string ids such as `design.theme = \"minimal\"`, `tone = \"formal\"`, or `chart.type = \"line\"`. Dense examples may also embed catalog sources or inline records under `catalogs`.\n\n## Audience\n\n- File: `spec/schemas/audience.schema.json`\n- Schema id: `https://openpresentation.org/schema/opf-audience/v1`\n- Type: `object`\n- Required fields: `$schema`, `id`, `name`\n- Purpose: Schema for audience records in the pptx.gallery library. Each record names an audience archetype (e.g. 'executive', 'engineering-team', 'investor') and carries seniority, technical-fluency, decision-power, and attention-budget hints used by AI-driven generation. Audiences are referenced from OPF documents via audience; the engine resolves the reference against catalogs.audiences (inline) catalogs.audiences.source the default catalog at https://www.pptx.gallery/audiences. The audience field al...\n\n| Field | Required | Type | Notes |\n| --- | --- | --- | --- |\n| `$schema` | yes | `const:\"https://openpresentation.org/schema/opf-audience/v1\"` | Identifies this record as an audience in the openpresentation.org catalog. |\n| `id` | yes | `string` | Stable slug used by OPF documents to reference this audience via audience. Lowercase kebab-case. |\n| `name` | yes | `string` | Human-readable audience name shown in pickers. |\n| `deprecation` | no | `object` | Present when this audience is deprecated, for example an alias kept for backward compatibility. Deprecated records stay resolvable so existing documents keep validating and rendering unchanged, but pickers and default... |\n| `summary` | no | `string` | One-sentence positioning of the audience who they are and what they care about. |\n| `description` | no | `string` | Longer prose describing the audience archetype and how to address them. |\n| `seniority` | no | `enum:ic \\| manager \\| director \\| vp \\| c-suite \\| mixed` | Typical seniority level of the audience. Engines use this as a hint for default depth and pacing. |\n| `technicalFluency` | no | `enum:low \\| medium \\| high \\| mixed` | Typical technical fluency of the audience. AI generation uses this to decide whether to expand or assume technical terminology. |\n| `decisionPower` | no | `enum:informational \\| advisory \\| decision-maker` | Whether the audience is expected to be informed, to advise, or to actually decide. Shapes the strength of the closing ask. |\n| `attentionBudgetMinutes` | no | `number` | Realistic upper bound on this audience's focused attention for a single presentation, in minutes. Used as a hint when comparing against duration and the resolved narrative's duration range. |\n| `recommendedNarratives` | no | `array<string>` | Soft cross-link: narrative-catalog ids that work well for this audience. Used by picker UIs to suggest narratives once an audience is chosen. Validators warn on unknown ids; never error. |\n| `recommendedTones` | no | `array<string>` | Soft cross-link: tone-catalog ids that work well for this audience. |\n| `tags` | no | `array<string>` | Free-form labels for filtering and search. |\n| `preview` | no | `object` | Visual previews of the record, used by picker UIs and inline rendering. All sub-fields are optional. |\n\n## Catalog Index\n\n- File: `spec/schemas/catalog-index.schema.json`\n- Schema id: `https://openpresentation.org/schema/opf-catalog-index/v1`\n- Type: `object`\n- Required fields: `$schema`, `version`, `description`, `records`\n- Purpose: Generic shape shared by every `spec/catalogs/<kind>/index.json` file in the OPF repo and by the default-catalog index that pptx.gallery publishes at `https://www.pptx.gallery/<kind>/index.json` (spec/catalogs is a pinned snapshot of that catalog; see docs/default-catalog.md). An index is a lightweight, ordered summary of the full-record JSON files that live alongside it: each entry names the record's stable id, a human-readable name, and the record's filename, plus whatever extra summary fiel...\n\n| Field | Required | Type | Notes |\n| --- | --- | --- | --- |\n| `$schema` | yes | `const:\"https://openpresentation.org/schema/opf-catalog-index/v1\"` | |\n| `kind` | no | `enum:audiences \\| chart-types \\| color-schemes \\| font-schemes \\| languages \\| layouts \\| narratives \\| purposes \\| social-platforms \\| themes \\| tones` | Catalog kind, as the URL segment of the default catalog (`https://www.pptx.gallery/<kind>`) and the `spec/catalogs/<kind>` directory name. |\n| `version` | yes | `string` | Index format version, as a string. |\n| `description` | yes | `string` | Human-readable description of what this catalog kind holds and how entries are ordered. |\n| `contentSha256` | no | `string` | SHA-256 (lowercase hex) of the canonical JSON of the full records this index lists, in index order, with every top-level `x-*` member removed. Canonical JSON sorts object keys and has no insignificant whitespace. Lets... |\n| `records` | yes | `array<ref:IndexRecord>` | Ordered list of lightweight record summaries. Order defines the catalog's canonical/display order; full record data lives in the sibling JSON file named by `file`. |\n\n### Nested Types\n\n#### IndexRecord\n\n- Type: `object`\n- Required fields: `id`, `name`, `file`\n- Purpose: Lightweight summary of one catalog record. Additional per-kind fields (e.g. `summary`, `tags`, `bcp47`, `duration`, `group`, `label`) are allowed and vary by catalog kind.\n\n| Field | Required | Type | Notes |\n| --- | --- | --- | --- |\n| `id` | yes | `string` | Stable identifier, matching the `id` field inside the record file named by `file`. Lowercase kebab-case; chart-type ids may start with a digit (e.g. '100pct-stacked-bar'). |\n| `name` | yes | `string` | Human-readable name shown in pickers. |\n| `file` | yes | `string` | Filename of the full record, relative to this index file's directory. |\n| `deprecated` | no | `const:true` | Present on the entry of a record that carries `deprecation`. Pickers and default listings hide deprecated entries; the id keeps resolving. |\n| `replacedBy` | no | `string` | The deprecated record's `deprecation.replacedBy`, repeated so a picker can offer the replacement without loading the record. |\n\n## Catalog Snapshot Manifest\n\n- File: `spec/schemas/catalog-manifest.schema.json`\n- Schema id: `https://openpresentation.org/schema/opf-catalog-manifest/v1`\n- Type: `object`\n- Required fields: `$schema`, `description`, `publisher`, `source`, `kinds`\n- Purpose: Shape of `spec/catalogs/manifest.json`, which pins the bundled catalogs to the default OPF catalog published by pptx.gallery. It records the gallery commit the snapshot came from and, per kind, how the snapshot relates to the published catalog plus a content hash of the bundled records. Written by scripts/sync-gallery-catalog.mjs and checked by scripts/check-spec-integrity.mjs; see docs/default-catalog.md. This schema describes a repo-internal file, not an OPF document or a catalog record; it...\n\n| Field | Required | Type | Notes |\n| --- | --- | --- | --- |\n| `$schema` | yes | `const:\"https://openpresentation.org/schema/opf-catalog-manifest/v1\"` | |\n| `description` | yes | `string` | |\n| `publisher` | yes | `string` | Base URL of the default-catalog publisher. Each kind is published at `<publisher>/<kind>/index.json`. |\n| `source` | yes | `object` | The published catalog files the snapshot was taken from. |\n| `kinds` | yes | `object` | One entry per catalog kind, keyed by the kind's URL segment. |\n\n### Nested Types\n\n#### KindEntry\n\n- Type: `object`\n- Required fields: `mode`, `records`, `contentSha256`, `gallery`\n\n| Field | Required | Type | Notes |\n| --- | --- | --- | --- |\n| `mode` | yes | `enum:mirror \\| subset` | 'mirror': the snapshot holds every published record. 'subset': the snapshot keeps its existing ids (their content comes from the publisher) while the publisher also serves records that are not reconciled for bundling... |\n| `records` | yes | `integer` | Number of records bundled for this kind. |\n| `contentSha256` | yes | `string` | contentSha256 of the bundled records, as defined by the catalog index schema. |\n| `gallery` | yes | `object` | The published catalog for this kind at the pinned commit. |\n\n## Chart Type\n\n- File: `spec/schemas/chart-type.schema.json`\n- Schema id: `https://openpresentation.org/schema/opf-chart-type/v1`\n- Type: `object`\n- Required fields: `$schema`, `id`, `name`, `mappings`\n- Purpose: Schema for chart-type records in the pptx.gallery catalog. The bundled catalog holds one record per chart type that Aspose.Slides officially supports (see mappings.renderers[\"aspose-slides\"].chartType), plus the column-and-line combination 'combo' (mappings.openxml.composition \"mixed\"). Each record names a chart variant, its Open XML mapping, how many data series it expects and how pickers group it. Chart types are referenced from OPF chart content payloads; the engine resolves the reference...\n\n| Field | Required | Type | Notes |\n| --- | --- | --- | --- |\n| `$schema` | yes | `const:\"https://openpresentation.org/schema/opf-chart-type/v1\"` | Identifies this record as a chart type in the open presentation catalog. |\n| `id` | yes | `string` | Stable slug used by OPF documents to reference this chart type (`chart.type`). Lowercase kebab-case. Chart type ids may start with a digit (e.g., '100pct-stacked-column', '3d-column') to mirror conventional chart naming. |\n| `name` | yes | `string` | Human-readable name of the chart type, as a chart picker shows it. |\n| `summary` | no | `string` | One-sentence positioning: when to reach for this chart variant. |\n| `description` | no | `string` | Longer prose describing the chart and ideal use cases. |\n| `mappings` | yes | `ref:ChartTypeMappings` | Canonical and optional renderer-specific mappings used by engines to render this chart type. |\n| `deprecation` | no | `ref:ChartTypeDeprecation` | Present when this chart type is deprecated. A deprecated record stays resolvable so existing documents keep validating, validators warn when a document references it, and pickers and generators should not offer it. Th... |\n| `group` | no | `string` | Top-level grouping in the chart picker. |\n| `groupSort` | no | `integer` | Display ordering hint within the chart group. |\n| `complexity` | no | `enum:simple \\| calculated \\| hierarchical \\| normalized` | Shape of the underlying data: a flat series ('simple'), one with engine-side calculation ('calculated'), parent-child rows ('hierarchical'), or pre-normalized rows ('normalized'). The editor offers only 'simple' types... |\n| `series` | no | `integer` | Number of data series this chart type expects: exactly that many, except that a stacked or percent-stacked type and a combination (composition 'mixed', such as combo) expect at least that many. The editor offers a typ... |\n| `tags` | no | `array<string>` | Free-form labels for filtering and search. |\n| `preview` | no | `object` | Visual previews of the record, used by picker UIs and inline rendering. All sub-fields are optional; engines fall back gracefully when previews aren't available. |\n\n### Nested Types\n\n#### ChartTypeDeprecation\n\n- Type: `object`\n- Required fields: `replacedBy`\n\n| Field | Required | Type | Notes |\n| --- | --- | --- | --- |\n| `replacedBy` | yes | `string` | Id of the non-deprecated chart type that documents should reference instead. |\n| `reason` | no | `string` | Why the record is deprecated. |\n| `removal` | no | `string` | Package version in which the record is scheduled for removal from the bundled catalog. |\n\n#### ChartTypeMappings\n\n- Type: `object`\n- Required fields: `openxml`\n\n| Field | Required | Type | Notes |\n| --- | --- | --- | --- |\n| `openxml` | yes | `ref:OpenXmlChartMapping` | Canonical mapping to Open XML chart structures. |\n| `renderers` | no | `object` | Optional renderer-specific mappings. Keys are renderer ids; values are intentionally opaque to OPF. The bundled catalog records the matching Aspose.Slides ChartType enumeration member under the \"aspose-slides\" key, e.... |\n\n#### OpenXmlChartMapping\n\n- Type: `object`\n- Required fields: none\n\n| Field | Required | Type | Notes |\n| --- | --- | --- | --- |\n| `element` | no | `string` | Primary Open XML chart element or extension chart element, such as 'barChart', 'lineChart', 'pieChart', 'treemapChart', or 'waterfallChart'. |\n| `barDir` | no | `enum:bar \\| col` | Bar direction for Open XML barChart mappings. |\n| `grouping` | no | `enum:standard \\| clustered \\| stacked \\| percentStacked` | Open XML chart grouping value when the chart family supports grouping. |\n| `marker` | no | `boolean` | Whether the chart type expects visible data markers. |\n| `radarStyle` | no | `enum:standard \\| marker \\| filled` | Open XML radarStyle value for radarChart mappings. |\n| `scatterStyle` | no | `enum:line \\| lineMarker \\| marker \\| smooth \\| smoothMarker` | Open XML scatterStyle value for scatterChart mappings. |\n| `composition` | no | `enum:single \\| mixed \\| extension` | Whether the chart maps to one standard chart element, multiple combined chart elements, or an Open XML extension chart. |\n| `extension` | no | `string` | Optional Open XML extension namespace or element hint for extension charts. |\n| `series` | no | `array<ref:OpenXmlChartMapping>` | Open XML chart elements used by mixed/composite chart types. |\n| `notes` | no | `string` | Short implementation note for mappings that need renderer interpretation. |\n\n## Color Scheme\n\n- File: `spec/schemas/color-scheme.schema.json`\n- Schema id: `https://openpresentation.org/schema/opf-color-scheme/v1`\n- Type: `object`\n- Required fields: `$schema`, `id`, `name`\n- Purpose: Schema for color-scheme records in the pptx.gallery library. Each scheme is a named palette with the twelve PowerPoint color slots (six accents, two darks, two lights, plus hyperlink and followed-hyperlink), suitable for being mapped directly into OOXML theme XML. Color schemes are referenced from OPF documents via design.colorScheme or design.colorScheme.id; the engine resolves the reference against catalogs.colorSchemes (inline) -> catalogs.colorSchemes.source -> the default catalog at http...\n\n| Field | Required | Type | Notes |\n| --- | --- | --- | --- |\n| `$schema` | yes | `const:\"https://openpresentation.org/schema/opf-color-scheme/v1\"` | Identifies this record as a color scheme in the openpresentation.org catalog. |\n| `id` | yes | `string` | Stable slug used by OPF documents to reference this color scheme. Lowercase kebab-case. |\n| `name` | yes | `string` | Human-readable scheme name shown in pickers. |\n| `deprecation` | no | `object` | Present when this color scheme is deprecated, for example an alias kept for backward compatibility. Deprecated records stay resolvable so existing documents keep validating and rendering unchanged, but pickers and def... |\n| `summary` | no | `string` | One-sentence positioning of the palette what mood it evokes and where to use it. |\n| `description` | no | `string` | Longer prose describing the palette and its intended use. |\n| `accent1` | no | `ref:HexColor` | Accent 1 color (hex). Mirrors the OOXML accent1 slot. |\n| `accent2` | no | `ref:HexColor` | Accent 2 color (hex). Mirrors the OOXML accent2 slot. |\n| `accent3` | no | `ref:HexColor` | Accent 3 color (hex). Mirrors the OOXML accent3 slot. |\n| `accent4` | no | `ref:HexColor` | Accent 4 color (hex). Mirrors the OOXML accent4 slot. |\n| `accent5` | no | `ref:HexColor` | Accent 5 color (hex). Mirrors the OOXML accent5 slot. |\n| `accent6` | no | `ref:HexColor` | Accent 6 color (hex). Mirrors the OOXML accent6 slot. |\n| `dark1` | no | `ref:HexColor` | Dark 1 color (hex). Typically the deepest neutral; OOXML dark1. |\n| `dark2` | no | `ref:HexColor` | Dark 2 color (hex). Secondary dark; OOXML dark2. |\n| `light1` | no | `ref:HexColor` | Light 1 color (hex). Typically the slide canvas; OOXML lt1. |\n| `light2` | no | `ref:HexColor` | Light 2 color (hex). Secondary light surface; OOXML lt2. |\n| `hyperlink` | no | `ref:HexColor` | Hyperlink color (hex). OOXML hlink. |\n| `followedHyperlink` | no | `ref:HexColor` | Followed-hyperlink color (hex). OOXML folHlink. |\n| `tags` | no | `array<string>` | Free-form labels for filtering and search. |\n| `preview` | no | `object` | Visual previews of the record, used by picker UIs and inline rendering. All sub-fields are optional; engines fall back gracefully when previews aren't available. |\n\n### Nested Types\n\n#### HexColor\n\n- Type: `string`\n- Required fields: none\n- Purpose: Hex color: '#RGB', '#RRGGBB' or '#RRGGBBAA'. The same definition as HexColor in opf.schema.json.\n\n_No named properties._\n\n## Font Scheme\n\n- File: `spec/schemas/font-scheme.schema.json`\n- Schema id: `https://openpresentation.org/schema/opf-font-scheme/v1`\n- Type: `object`\n- Required fields: `$schema`, `id`, `name`, `major`, `minor`\n- Purpose: Schema for font-scheme records in the pptx.gallery library. Each scheme pairs a major (heading) and minor (body) font family in the OOXML majorFont/minorFont sense, scoped to a target app (powerpoint or google-slides) and a language family (Latin, East Asian, or Complex Script). Font schemes are referenced from OPF documents via design.fontScheme or design.fontScheme.id; the engine resolves the reference against catalogs.fontSchemes (inline) catalogs.fontSchemes.source the default catalog at...\n\n| Field | Required | Type | Notes |\n| --- | --- | --- | --- |\n| `$schema` | yes | `const:\"https://openpresentation.org/schema/opf-font-scheme/v1\"` | Identifies this record as a font scheme in the openpresentation.org catalog. |\n| `id` | yes | `string` | Stable slug used by OPF documents to reference this font scheme. Lowercase kebab-case. |\n| `name` | yes | `string` | Human-readable scheme name shown in pickers. |\n| `deprecation` | no | `object` | Present when this font scheme is deprecated, for example an alias kept for backward compatibility. Deprecated records stay resolvable so existing documents keep validating and rendering unchanged, but pickers and defa... |\n| `major` | yes | `string` | Heading (major) font family mirrors the OOXML majorFont entry. |\n| `minor` | yes | `string` | Body (minor) font family mirrors the OOXML minorFont entry. |\n| `code` | no | `string` | Optional monospaced font family name for code blocks and for inline code runs (TextRun.code). It has the same shape as the OPF FontScheme 'code' role, so a record and an inline design.fontScheme override are interchan... |\n| `eastAsian` | no | `object` | East Asian script fonts. Maps to the OOXML a:ea element of majorFont (major) and minorFont (minor), and to run-level a:ea. When set, they fill the eastAsian slot for every language; when omitted, the slot comes from t... |\n| `complexScript` | no | `object` | Complex-script fonts (for example Arabic, Hebrew, Indic and Thai). Maps to the OOXML a:cs element of majorFont (major) and minorFont (minor), and to run-level a:cs. When set, they fill the complexScript slot for every... |\n| `type` | no | `enum:sans-serif \\| serif \\| monospace` | High-level typographic class of the scheme. |\n| `app` | no | `enum:powerpoint \\| google-slides` | Target application this font pairing is intended for. Metadata for pickers and catalog filters: no engine changes its output by it. |\n| `languageFamily` | no | `enum:latin \\| ea \\| cs \\| eastAsian \\| complexScript` | Font-language family this scheme is intended for: 'latin' for Latin-script content, 'ea' (or 'eastAsian', the same value) for East Asian scripts, 'cs' (or 'complexScript', the same value) for Complex Scripts. The long... |\n| `languages` | no | `array<string>` | Optional list of languages catalog ids (see the languages catalog, https://www.pptx.gallery/languages) this scheme is curated for. Useful for picker UIs that group fonts by language coverage. As the design font scheme... |\n| `textSample` | no | `string` | Short specimen string used by picker UIs to preview the scheme. |\n| `summary` | no | `string` | One-sentence positioning of the font pairing. |\n| `description` | no | `string` | Longer prose describing the font scheme and where it shines. |\n| `tags` | no | `array<string>` | Free-form labels for filtering and search. |\n| `preview` | no | `object` | Visual previews of the record, used by picker UIs and inline rendering. All sub-fields are optional; engines fall back gracefully when previews aren't available. |\n\n## Language\n\n- File: `spec/schemas/language.schema.json`\n- Schema id: `https://openpresentation.org/schema/opf-language/v1`\n- Type: `object`\n- Required fields: `$schema`, `id`, `name`, `bcp47`\n- Purpose: Schema for language records in the pptx.gallery library. Each record names a presentation language, carries a BCP-47 language tag, and pairs it with sensible default font schemes for PowerPoint and Google Slides output. Languages are referenced from OPF documents via language; the engine resolves the reference against catalogs.languages (inline) catalogs.languages.source the default catalog at https://www.pptx.gallery/languages. The presentation language field also accepts BCP-47 tags directl...\n\n| Field | Required | Type | Notes |\n| --- | --- | --- | --- |\n| `$schema` | yes | `const:\"https://openpresentation.org/schema/opf-language/v1\"` | Identifies this record as a language in the openpresentation.org catalog. |\n| `id` | yes | `string` | Stable slug used by OPF documents to reference this language via language. Lowercase kebab-case. |\n| `name` | yes | `string` | Human-readable language name. |\n| `deprecation` | no | `object` | Present when this language is deprecated, for example an alias kept for backward compatibility. Deprecated records stay resolvable so existing documents keep validating and rendering unchanged, but pickers and default... |\n| `code` | no | `string` | ISO 639-3 (or 639-2) three-letter language code. Carried for engines that prefer ISO codes. |\n| `bcp47` | yes | `string` | BCP-47 language tag for this record. Use 'en-GB' for UK English; 'en-UK' is not a valid BCP-47 region form. |\n| `ooxmlLang` | no | `string` | Curated culture tag for OOXML text-run language attributes (a:rPr/@lang, a:endParaRPr/@lang), in the language-[Script-]REGION form Office recognizes (e.g. 'ja-JP', 'ar-SA', 'ms-MY', 'nb-NO', 'fil-PH', 'zh-CN'). Engine... |\n| `direction` | no | `enum:ltr \\| rtl` | Base text direction for the language. When omitted, engines derive it from the script: Arabic (Arab), Hebrew (Hebr), Syriac (Syrc), Thaana (Thaa), N'Ko (Nkoo), Adlam (Adlm), Samaritan (Samr), Mandaic (Mand) and Hanifi... |\n| `script` | no | `string` | ISO 15924 script code of the language's writing system. The script selects the OOXML font slot the language's text uses: East Asian scripts (Hans, Hant, Hani, Jpan, Kore, Hang, Hira, Kana, Bopo, Yiii) use the eastAsia... |\n| `fontScheme` | no | `string` | Default font-scheme id for this language when targeting PowerPoint output. Resolves against catalogs.fontSchemes the same way design.fontScheme or design.fontScheme.id does. Its major/minor families fill the language'... |\n| `googleFontScheme` | no | `string` | Default font-scheme id for this language when targeting Google Slides output. Resolves against catalogs.fontSchemes the same way design.fontScheme or design.fontScheme.id does. Used in place of 'fontScheme' when resol... |\n| `summary` | no | `string` | One-sentence note about coverage or font defaults. |\n| `description` | no | `string` | Longer prose describing the language record and any font-pairing rationale. |\n| `tags` | no | `array<string>` | Free-form labels for filtering and search. |\n| `preview` | no | `object` | Visual previews of the record, used by picker UIs and inline rendering. All sub-fields are optional; engines fall back gracefully when previews aren't available. |\n\n## Layout Preview Index\n\n- File: `spec/schemas/layout-preview-index.schema.json`\n- Schema id: `https://openpresentation.org/schema/opf-layout-preview-index/v1`\n- Type: `object`\n- Required fields: `$schema`, `version`, `description`, `records`\n- Purpose: Shape of `spec/previews/layouts/index.json`, the manifest for the vendored slide-archetype preview gallery under `spec/previews/layouts/`. Each record names a preview id, its self-contained HTML file, and the file's exact UTF-8 byte length. These preview ids are an archetype taxonomy (e.g. 'swot-analysis', 'org-chart') distinct from the structural layout catalog at spec/catalogs/layouts/ (e.g. 'title', 'chart-2x') see spec/README.md. This schema describes a repo-internal index file, not an OP...\n\n| Field | Required | Type | Notes |\n| --- | --- | --- | --- |\n| `$schema` | yes | `const:\"https://openpresentation.org/schema/opf-layout-preview-index/v1\"` | |\n| `version` | yes | `string` | Index format version, as a string. |\n| `description` | yes | `string` | Human-readable description of the preview gallery and its rendering conventions. |\n| `records` | yes | `array<ref:PreviewRecord>` | One entry per vendored preview HTML file. |\n\n### Nested Types\n\n#### PreviewRecord\n\n- Type: `object`\n- Required fields: `id`, `file`, `bytes`\n- Purpose: Summary of one vendored preview HTML file.\n\n| Field | Required | Type | Notes |\n| --- | --- | --- | --- |\n| `id` | yes | `string` | Slide-archetype preview id (e.g. 'swot-analysis', 'agenda', 'org-chart'). Does not correspond to a spec/catalogs/layouts/ record id. |\n| `file` | yes | `string` | HTML filename, relative to this index file's directory. |\n| `bytes` | yes | `integer` | Exact UTF-8 byte length of the referenced HTML file's contents. |\n\n## Slide Layout\n\n- File: `spec/schemas/layout.schema.json`\n- Schema id: `https://openpresentation.org/schema/opf-layout/v1`\n- Type: `object`\n- Required fields: `$schema`, `id`, `name`\n- Purpose: Schema for slide-layout records in the pptx.gallery library. Each record describes a semantic slide layout what regions it exposes and what content kinds those regions are intended to hold. Layouts are referenced from OPF documents via Slide.layout; the engine resolves the reference against catalogs.layouts (inline) catalogs.layouts.source the default catalog at https://www.pptx.gallery/layouts. Free-form custom layout names that don't resolve through any catalog fall through to engine-define...\n\n| Field | Required | Type | Notes |\n| --- | --- | --- | --- |\n| `$schema` | yes | `const:\"https://openpresentation.org/schema/opf-layout/v1\"` | Identifies this record as a slide layout in the openpresentation.org catalog. |\n| `id` | yes | `string` | Stable slug used by OPF documents to reference this layout via Slide.layout. Lowercase kebab-case. |\n| `name` | yes | `string` | Human-readable layout name shown in layout pickers. |\n| `deprecation` | no | `object` | Present when this layout is deprecated, for example an alias kept for backward compatibility. Deprecated records stay resolvable so existing documents keep validating and rendering unchanged, but pickers and default l... |\n| `summary` | no | `string` | One-sentence positioning of the layout when to reach for it. |\n| `description` | no | `string` | Longer prose describing the layout structure and ideal use cases. |\n| `design` | no | `ref:DesignHints` | The layout's design hints, with the same keys and values as the deck's design and a slide's design (a slide overrides exactly what its layout sets, by the same name). An absent key means the layout has no opinion. Eve... |\n| `placeholders` | no | `array<ref:Placeholder>` | Ordered regions the layout exposes. This is the single source of truth for what the layout holds: the content kind, the number of body regions and whether it has a title, subtitle or tag are derived from it (layoutCon... |\n| `tags` | no | `array<string>` | Free-form labels for filtering and search. |\n| `preview` | no | `object` | Visual previews of the record, used by picker UIs and inline rendering. All sub-fields are optional; engines fall back gracefully when previews aren't available. |\n| `composition` | no | `ref:Composition` | |\n\n### Nested Types\n\n#### Placeholder\n\n- Type: `object`\n- Required fields: `type`\n- Purpose: A single region inside a slide layout. Title, subtitle, and tag placeholders bind to the corresponding Slide fields; every other placeholder describes the content kind that region holds. The array order in the surrounding 'placeholders' field preserves layout region order.\n\n| Field | Required | Type | Notes |\n| --- | --- | --- | --- |\n| `type` | yes | `enum:title \\| subtitle \\| tag \\| text \\| list \\| image \\| video \\| chart \\| table \\| code \\| metric \\| quote \\| timeline` | The content kind of the region. This is the one OPF content-kind vocabulary: the same words name a slide's payload fields (text, items, image, video, chart, table, code, metric, quote, timeline). 'title', 'subtitle' a... |\n\n#### DesignHints\n\n- Type: `object`\n- Required fields: none\n- Purpose: Design hints a layout carries. The keys and lowercase values are those of the deck's design and a slide's design in opf.schema.json (Design), so a slide overrides a layout by the same name and layout.design can be copied into slide.design unchanged. An absent key means the layout has no opinion. Only the keys the layout sets are stored. scripts/check-spec-integrity.mjs verifies that every key here has the same typ...\n\n| Field | Required | Type | Notes |\n| --- | --- | --- | --- |\n| `titleAlignment` | no | `enum:left \\| center \\| right` | Horizontal alignment of the title placeholder. |\n| `contentAlignment` | no | `enum:left \\| center \\| right` | Horizontal alignment of the body regions. |\n| `contentBox` | no | `boolean` | Whether the body regions are drawn inside a visible card or surface. |\n| `contentDirection` | no | `enum:horizontal \\| vertical` | Axis along which parallel body content is arranged: 'vertical' is a column, 'horizontal' a row. Composition applies it when neither the slide nor this layout's composition sets a mode, below the slide's and the deck's... |\n| `chartPrimary` | no | `enum:none \\| top \\| bottom \\| left \\| right` | Where the primary chart sits relative to the other body content. Composition applies it below the slide's and the deck's design.chartPrimary, with the semantics described there. |\n| `imageFill` | no | `enum:crop \\| fit` | How images fill their region: 'crop' covers the region, 'fit' shows the whole image. |\n| `listBullet` | no | `enum:character \\| image` | Marker style of lists: 'character' draws the glyph, 'image' draws the deck's icon logo as a picture bullet. |\n| `slideImage` | no | `object` | A slide-level image region the layout reserves, separate from any content image. Its presence is what lets a deck-wide design.slideImage apply to slides on this layout. The image itself comes from the slide or the dec... |\n\n#### Composition\n\n- Type: `object`\n- Required fields: none\n- Purpose: Portable dynamic composition. Slide fields override the resolved layout. Nested groups arrange their children independently, inheriting only minFontSize and overflow. Explicit promoted regions retain their positions.\n\n| Field | Required | Type | Notes |\n| --- | --- | --- | --- |\n| `mode` | no | `enum:auto \\| grid \\| row \\| column` | auto chooses a grid from available space and content; grid uses columns; row and column use one horizontal or vertical track. |\n| `columns` | no | `integer` | Column count for grid. In auto mode this caps the number of columns. |\n| `gap` | no | `number` | Space between cells as a fraction of the container short edge (canvas at slide root). Default 0.03333333333333333. |\n| `padding` | no | `number` | Inset as a fraction of the container short edge. Default 0.08 on a slide, 0 inside a group. |\n| `weights` | no | `array<number>` | Relative track sizes: columns for row/grid/auto, rows for column. Omitted tracks have weight 1; extra weights are ignored. |\n| `minFontSize` | no | `number` | Minimum readable text size in reference pixels at a 720-pixel canvas short edge. Default 16. Overflow is diagnosed when text cannot fit at this size. |\n| `overflow` | no | `enum:warn \\| error` | warn returns diagnostics for content that does not fit; error rejects layout. Content is never silently removed. Default warn. |\n\n## Narrative Template\n\n- File: `spec/schemas/narrative.schema.json`\n- Schema id: `https://openpresentation.org/schema/opf-narrative/v1`\n- Type: `object`\n- Required fields: `$schema`, `id`, `name`, `beats`\n- Purpose: Schema for narrative template files in the openpresentation.org catalog. A narrative is a plan: a named story arc (e.g. 'problem-solution', 'scqa') as an ordered list of beats, each saying what its slide must do. A document points at one with the string 'narrative' (a catalog id, an HTTPS URL or a 'pkg:' reference) and links its slides to beats with 'slides[].beat'. A custom narrative is a record in 'catalogs.narratives.records'. Core validate warns about a 'slides[].beat' id the resolved nar...\n\n| Field | Required | Type | Notes |\n| --- | --- | --- | --- |\n| `$schema` | yes | `const:\"https://openpresentation.org/schema/opf-narrative/v1\"` | |\n| `id` | yes | `string` | Stable slug used by OPF documents to reference this template, e.g. 'problem-solution'. Lowercase kebab-case. |\n| `name` | yes | `string` | Human-readable template name, e.g. 'Problem Solution'. |\n| `deprecation` | no | `object` | Present when this narrative is deprecated, for example an alias kept for backward compatibility. Deprecated records stay resolvable so existing documents keep validating and rendering unchanged, but pickers and defaul... |\n| `summary` | no | `string` | One-sentence description of when and why to use this narrative. |\n| `description` | no | `string` | Longer prose describing the narrative arc and ideal use cases. Used by AI-driven generation to seed deck-level direction. |\n| `audienceFit` | no | `array<string>` | Audiences this narrative works well for, e.g. ['executive', 'investor', 'customer']. |\n| `duration` | no | `object` | Typical talk length this narrative suits, as a range in minutes. A deck's own target is the root 'duration' (one number); core validate warns when that target lies outside this range, and when 'min' is greater than 'm... |\n| `tags` | no | `array<string>` | Free-form labels for filtering and search, e.g. ['business', 'pitch', 'internal']. |\n| `preview` | no | `object` | Visual previews of the record, used by picker UIs and inline rendering. All sub-fields are optional; engines fall back gracefully when previews aren't available. |\n| `beats` | yes | `array<ref:Beat>` | Ordered list of beats that make up the narrative arc. |\n\n### Nested Types\n\n#### Beat\n\n- Type: `object`\n- Required fields: `id`, `name`\n- Purpose: A single narrative beat a labeled segment of the story arc with a specific dramatic purpose. One beat is one slide: 'type' is that slide's Slide.type and 'layout' is its Slide.layout, so a planner copies them straight into a skeleton deck. Split a heavy beat into several beats rather than giving it several slides.\n\n| Field | Required | Type | Notes |\n| --- | --- | --- | --- |\n| `id` | yes | `string` | Stable slug used by Slide.beat to reference this beat. Lowercase kebab-case. |\n| `name` | yes | `string` | Human-readable beat name, e.g. 'The Problem'. |\n| `description` | no | `string` | Curator-written prose that explains what this beat should accomplish. |\n| `instructions` | no | `string` | Short author-facing instruction for the beat typically one phrase. Complements 'description' with a concise directive. |\n| `type` | no | `enum:text \\| list \\| image \\| video \\| chart \\| table \\| code \\| metric \\| quote \\| timeline` | Default content kind for the beat's slide: the same values as Slide.type. Helps engines and planners choose a layout when only the beat is known. |\n| `layout` | no | `string` | Suggested layout id for the beat's slide, e.g. 'section-divider', 'title-slide', 'text-left'. Resolves the same way as Slide.layout against catalogs.layouts and the default catalog at https://www.pptx.gallery/layouts. |\n| `thoughtCues` | no | `array<string>` | Optional speaker or thinking cues for the beat: questions the slide should answer. A planner or author turns them into the slide's notes; no engine copies them there on its own. |\n\n## Purpose\n\n- File: `spec/schemas/purpose.schema.json`\n- Schema id: `https://openpresentation.org/schema/opf-purpose/v1`\n- Type: `object`\n- Required fields: `$schema`, `id`, `name`\n- Purpose: Schema for purpose records in the pptx.gallery library. Each record names a presentation objective such as informing, aligning, persuading, driving a decision, or selling. Purposes are referenced from OPF documents via purpose; the engine resolves the reference against catalogs.purposes (inline) catalogs.purposes.source the default catalog at https://www.pptx.gallery/purposes. The purpose field also accepts free-form strings and inline Purpose objects.\n\n| Field | Required | Type | Notes |\n| --- | --- | --- | --- |\n| `$schema` | yes | `const:\"https://openpresentation.org/schema/opf-purpose/v1\"` | Identifies this record as a purpose in the openpresentation.org catalog. |\n| `id` | yes | `string` | Stable slug used by OPF documents to reference this purpose via purpose. Lowercase kebab-case. |\n| `name` | yes | `string` | Human-readable purpose name shown in pickers. |\n| `deprecation` | no | `object` | Present when this purpose is deprecated, for example an alias kept for backward compatibility. Deprecated records stay resolvable so existing documents keep validating and rendering unchanged, but pickers and default... |\n| `summary` | no | `string` | One-sentence positioning of the purpose what this deck is trying to accomplish. |\n| `description` | no | `string` | Longer prose describing when to use this purpose and how it should shape a deck. |\n| `outcome` | no | `string` | Desired audience outcome after the presentation. |\n| `successCriteria` | no | `array<string>` | Observable signals that the deck accomplished this purpose. |\n| `recommendedNarratives` | no | `array<string>` | Soft cross-link: narrative-catalog ids that work well for this purpose. |\n| `recommendedTones` | no | `array<string>` | Soft cross-link: tone-catalog ids that work well for this purpose. |\n| `tags` | no | `array<string>` | Free-form labels for filtering and search. |\n| `preview` | no | `object` | Visual previews of the record, used by picker UIs and inline rendering. All sub-fields are optional. |\n\n## Social Platform\n\n- File: `spec/schemas/social-platform.schema.json`\n- Schema id: `https://openpresentation.org/schema/opf-social-platform/v1`\n- Type: `object`\n- Required fields: `$schema`, `id`, `name`\n- Purpose: Schema for social-platform records in the pptx.gallery library. Each record describes a single social-media platform its base URL, profile-URL pattern, handle prefix, and brand color. Records are referenced from OPF documents indirectly: the property keys of any Socials object (Organization.socials, Speaker.socials) match record ids, and engines use the catalog record's URL patterns and handle prefix to format and link the profile URL. The brand color is catalog metadata for authoring UIs: en...\n\n| Field | Required | Type | Notes |\n| --- | --- | --- | --- |\n| `$schema` | yes | `const:\"https://openpresentation.org/schema/opf-social-platform/v1\"` | Identifies this record as a social-platform entry in the openpresentation.org catalog. |\n| `id` | yes | `string` | Stable slug used by OPF documents to reference this platform appears as a property key on Socials objects. Lowercase kebab-case. |\n| `name` | yes | `string` | Human-readable platform name shown in pickers and footers. |\n| `deprecation` | no | `object` | Present when this social platform is deprecated, for example an alias kept for backward compatibility. Deprecated records stay resolvable so existing documents keep validating and rendering unchanged, but pickers and... |\n| `summary` | no | `string` | One-sentence positioning of the platform what it's used for and who's on it. |\n| `description` | no | `string` | Longer prose describing the platform and any rendering conventions (e.g., handle prefixes, distributed instances). |\n| `baseUrl` | no | `string` | Canonical base URL of the platform used as the prefix when normalizing handles to full URLs. |\n| `profileUrlPattern` | no | `string` | URL pattern for individual member profiles. Use '{handle}' as the placeholder for the handle (with the prefix already stripped). |\n| `companyUrlPattern` | no | `string` | Optional URL pattern for organization / company pages, when the platform distinguishes them from member profiles. Use '{handle}' as the placeholder. |\n| `handlePrefix` | no | `string` | Conventional prefix character displayed before the handle (e.g. '@' for X / Mastodon / Threads / TikTok). Empty string when no prefix is used. Renderers strip it before substituting into URL patterns. |\n| `handleExample` | no | `string` | Example handle in its conventional rendered form, used by picker UIs and validation hints. |\n| `brandColor` | no | `string` | Brand color (hex) for branded icon chips, link styling, or section accents in authoring UIs. Catalog metadata: engines do not draw it. |\n| `tags` | no | `array<string>` | Free-form labels for filtering and search. |\n| `preview` | no | `object` | Visual previews of the record, used by picker UIs and inline rendering. All sub-fields are optional; engines fall back gracefully when previews aren't available. |\n\n## Theme\n\n- File: `spec/schemas/theme.schema.json`\n- Schema id: `https://openpresentation.org/schema/opf-theme/v1`\n- Type: `object`\n- Required fields: `$schema`, `id`, `name`\n- Purpose: Schema for theme records in the pptx.gallery library. Each theme is a small, named bundle that pairs a color scheme, a font scheme, a default theme-controlled background, and a slide size. Themes are referenced from OPF documents via design.theme or design.theme.id; the engine resolves the reference against catalogs.themes (inline) catalogs.themes.source the default catalog at https://www.pptx.gallery/themes. Inline overrides on design.colorScheme / design.fontScheme / design.background / des...\n\n| Field | Required | Type | Notes |\n| --- | --- | --- | --- |\n| `$schema` | yes | `const:\"https://openpresentation.org/schema/opf-theme/v1\"` | Identifies this record as a theme in the openpresentation.org catalog. |\n| `id` | yes | `string` | Stable slug used by OPF documents to reference this theme via design.theme. Lowercase kebab-case. |\n| `name` | yes | `string` | Human-readable theme name shown in pickers. |\n| `deprecation` | no | `object` | Present when this theme is deprecated, for example an alias kept for backward compatibility. Deprecated records stay resolvable so existing documents keep validating and rendering unchanged, but pickers and default li... |\n| `summary` | no | `string` | One-sentence positioning of the theme when to reach for it. |\n| `description` | no | `string` | Longer prose describing what the theme looks and feels like and the kinds of decks it suits. |\n| `colorScheme` | no | `string` | Catalog reference to the theme's default color scheme resolved against catalogs.colorSchemes the same way design.colorScheme or design.colorScheme.id is. Accepts a bare id, HTTPS URL, or 'pkg:' reference. |\n| `fontScheme` | no | `string` | Catalog reference to the theme's default font scheme resolved against catalogs.fontSchemes the same way design.fontScheme or design.fontScheme.id is. Accepts a bare id, HTTPS URL, or 'pkg:' reference. |\n| `background` | no | `ref:ThemeBackground` | |\n| `dimensions` | no | `enum:16:9 \\| 4:3 \\| 16:10 \\| 1:1 \\| 4:5 \\| 9:16 \\| letter \\| a4 \\| widescreen \\| standard` | Default slide size for this theme. Accepts the same preset values as design.dimensions.preset. |\n| `tags` | no | `array<string>` | Free-form labels for filtering and search. |\n| `preview` | no | `object` | Visual previews of the record, used by picker UIs and inline rendering. All sub-fields are optional; engines fall back gracefully when previews aren't available. |\n\n### Nested Types\n\n#### ThemeBackgroundSlot\n\n- Type: `enum:light1 | light2 | dark1 | dark2`\n- Required fields: none\n- Purpose: PowerPoint theme-controlled slide background slot from the active color scheme. These are slots, not assumptions about actual colors: light1 is usually white and dark1 is usually black by convention, but the color scheme controls the real values.\n\n_No named properties._\n\n#### ThemeBackground\n\n- Type: `object`\n- Required fields: `type`, `slot`\n- Purpose: Theme-controlled PowerPoint slide background. The slot is resolved through the active color scheme and remains theme-aware.\n\n| Field | Required | Type | Notes |\n| --- | --- | --- | --- |\n| `type` | yes | `const:\"theme\"` | Theme-controlled background fill. |\n| `slot` | yes | `ref:ThemeBackgroundSlot` | |\n\n## Tone\n\n- File: `spec/schemas/tone.schema.json`\n- Schema id: `https://openpresentation.org/schema/opf-tone/v1`\n- Type: `object`\n- Required fields: `$schema`, `id`, `name`\n- Purpose: Schema for tone records in the pptx.gallery library. Each record names a presentation tone (e.g. 'formal', 'casual', 'inspirational') and carries voice cues, anti-patterns, and sample phrases that AI-driven generation uses to shape output. Tones are referenced from OPF documents via tone; the engine resolves the reference against catalogs.tones (inline) catalogs.tones.source the default catalog at https://www.pptx.gallery/tones.\n\n| Field | Required | Type | Notes |\n| --- | --- | --- | --- |\n| `$schema` | yes | `const:\"https://openpresentation.org/schema/opf-tone/v1\"` | Identifies this record as a tone in the openpresentation.org catalog. |\n| `id` | yes | `string` | Stable slug used by OPF documents to reference this tone via tone. Lowercase kebab-case. |\n| `name` | yes | `string` | Human-readable tone name shown in pickers. |\n| `deprecation` | no | `object` | Present when this tone is deprecated, for example an alias kept for backward compatibility. Deprecated records stay resolvable so existing documents keep validating and rendering unchanged, but pickers and default lis... |\n| `summary` | no | `string` | One-sentence positioning of the tone when to reach for it. |\n| `description` | no | `string` | Longer prose describing the tone and the kinds of decks it suits. |\n| `voiceCues` | no | `array<string>` | Short directives that shape AI generation toward this tone. Phrased as imperatives, e.g. 'use second-person', 'favor short sentences', 'lead with the recommendation'. |\n| `avoid` | no | `array<string>` | Anti-patterns that AI generation should not produce when this tone is active. |\n| `samplePhrases` | no | `array<string>` | Short example phrases that exemplify this tone. Used by picker UIs and as few-shot examples for AI generation. |\n| `recommendedNarratives` | no | `array<string>` | Soft cross-link: narrative-catalog ids this tone pairs well with. Used by picker UIs to suggest narratives once a tone is chosen. Validators warn on unknown ids; never error. |\n| `tags` | no | `array<string>` | Free-form labels for filtering and search. |\n| `preview` | no | `object` | Visual previews of the record, used by picker UIs and inline rendering. All sub-fields are optional. |\n"
13
+ "markdown": "# OPF Catalog Schema Reference\n\nCatalog records are reusable presets that OPF documents reference by id. This page summarizes every catalog companion schema in `spec/schemas/`. The top-level presentation schema has its own [schema reference](schema-reference.md), and the report format every tool shares has the [finding schema reference](finding-schema-reference.md).\n\nOPF documents usually reference these records with string ids such as `design.theme = \"minimal\"`, `tone = \"formal\"`, or `chart.type = \"line\"`. Dense examples may also embed catalog sources or inline records under `catalogs`.\n\n## Audience\n\n- File: `spec/schemas/audience.schema.json`\n- Schema id: `https://openpresentation.org/schema/opf-audience/v1`\n- Type: `object`\n- Required fields: `$schema`, `id`, `name`\n- Purpose: Schema for audience records. Each record names an audience archetype (e.g. 'executive', 'engineering-team', 'investor') and carries seniority, technical-fluency, decision-power, and attention-budget hints used by AI-driven generation. Documents reference an audience from 'audience', which also accepts free-form text. A document references a record as a bare id or 'name:id' and resolves it in its own catalogs groups first, then in the catalog the host registered for the group's source; engines...\n\n| Field | Required | Type | Notes |\n| --- | --- | --- | --- |\n| `$schema` | yes | `const:\"https://openpresentation.org/schema/opf-audience/v1\"` | Identifies a published record file as a audience record. Required in a published file; an embedded copy omits it. |\n| `id` | yes | `string` | The audience's id in its catalog, lowercase kebab-case. Required in a published record file; a document references the record by it (a bare id, or 'name:id' for a named group) and embeds it keyed by it, without this f... |\n| `name` | yes | `string` | Human-readable audience name shown in pickers. |\n| `summary` | no | `string` | One-sentence positioning of the audience who they are and what they care about. |\n| `description` | no | `string` | Longer prose describing the audience archetype and how to address them. |\n| `seniority` | no | `enum:ic \\| manager \\| director \\| vp \\| c-suite \\| mixed` | Typical seniority level of the audience. Engines use this as a hint for default depth and pacing. |\n| `technicalFluency` | no | `enum:low \\| medium \\| high \\| mixed` | Typical technical fluency of the audience. AI generation uses this to decide whether to expand or assume technical terminology. |\n| `decisionPower` | no | `enum:informational \\| advisory \\| decision-maker` | Whether the audience is expected to be informed, to advise, or to actually decide. Shapes the strength of the closing ask. |\n| `attentionBudgetMinutes` | no | `number` | Realistic upper bound on this audience's focused attention for a single presentation, in minutes. Used as a hint when comparing against duration and the resolved narrative's duration range. |\n| `recommendedNarratives` | no | `array<string>` | Soft cross-link: ids of narratives in the same catalog that work well for this audience. Used by picker UIs to suggest narratives once an audience is chosen. Never resolved, embedded or validated as references. |\n| `recommendedTones` | no | `array<string>` | Soft cross-link: ids of tones in the same catalog that work well for this audience. Never resolved, embedded or validated as references. |\n| `tags` | no | `array<string>` | Free-form labels for filtering and search. |\n| `preview` | no | `object` | Visual previews of the record, used by picker UIs and inline rendering. All sub-fields are optional. |\n\n## Catalog Index\n\n- File: `spec/schemas/catalog-index.schema.json`\n- Schema id: `https://openpresentation.org/schema/opf-catalog-index/v1`\n- Type: `object`\n- Required fields: `$schema`, `version`, `description`, `records`\n- Purpose: Generic shape shared by every `spec/catalogs/<kind>/index.json` file in the OPF repo and by the default-catalog index that pptx.gallery publishes at `https://www.pptx.gallery/<kind>/index.json` (spec/catalogs is a pinned snapshot of that catalog; see docs/default-catalog.md). An index is a lightweight, ordered summary of the full-record JSON files that live alongside it: each entry names the record's stable id, a human-readable name, and the record's filename, plus whatever extra summary fiel...\n\n| Field | Required | Type | Notes |\n| --- | --- | --- | --- |\n| `$schema` | yes | `const:\"https://openpresentation.org/schema/opf-catalog-index/v1\"` | |\n| `kind` | no | `enum:audiences \\| chart-types \\| color-schemes \\| font-schemes \\| languages \\| layouts \\| narratives \\| purposes \\| social-platforms \\| themes \\| tones` | Catalog kind, as the URL segment of the default catalog (`https://www.pptx.gallery/<kind>`) and the `spec/catalogs/<kind>` directory name. |\n| `version` | yes | `string` | Index format version, as a string. |\n| `description` | yes | `string` | Human-readable description of what this catalog kind holds and how entries are ordered. |\n| `contentSha256` | no | `string` | SHA-256 (lowercase hex) of the canonical JSON of the full records this index lists, in index order, with every top-level `x-*` member removed. Canonical JSON sorts object keys and has no insignificant whitespace. Lets... |\n| `records` | yes | `array<ref:IndexRecord>` | Ordered list of lightweight record summaries. Order defines the catalog's canonical/display order; full record data lives in the sibling JSON file named by `file`. |\n\n### Nested Types\n\n#### IndexRecord\n\n- Type: `object`\n- Required fields: `id`, `name`, `file`\n- Purpose: Lightweight summary of one catalog record. Additional per-kind fields (e.g. `summary`, `tags`, `bcp47`, `duration`, `group`, `label`) are allowed and vary by catalog kind.\n\n| Field | Required | Type | Notes |\n| --- | --- | --- | --- |\n| `id` | yes | `string` | Stable identifier, matching the `id` field inside the record file named by `file`. Lowercase kebab-case; chart-type ids may start with a digit (e.g. '100pct-stacked-bar'). |\n| `name` | yes | `string` | Human-readable name shown in pickers. |\n| `file` | yes | `string` | Filename of the full record, relative to this index file's directory. |\n| `deprecated` | no | `const:true` | Present on the entry of a record that carries `deprecation`. Pickers and default listings hide deprecated entries; the id keeps resolving. |\n| `replacedBy` | no | `string` | The deprecated record's `deprecation.replacedBy`, repeated so a picker can offer the replacement without loading the record. |\n\n## Catalog Snapshot Manifest\n\n- File: `spec/schemas/catalog-manifest.schema.json`\n- Schema id: `https://openpresentation.org/schema/opf-catalog-manifest/v1`\n- Type: `object`\n- Required fields: `$schema`, `description`, `publisher`, `source`, `kinds`\n- Purpose: Shape of `spec/catalogs/manifest.json`, which pins the snapshot of the default OPF catalog published by pptx.gallery (shipped as @openpresentation/opf/catalog). It records the gallery commit the snapshot came from and, per kind, how the snapshot relates to the published catalog plus a content hash of the bundled records. Written by scripts/sync-gallery-catalog.mjs and checked by scripts/check-spec-integrity.mjs; see docs/default-catalog.md. This schema describes a repo-internal file, not an O...\n\n| Field | Required | Type | Notes |\n| --- | --- | --- | --- |\n| `$schema` | yes | `const:\"https://openpresentation.org/schema/opf-catalog-manifest/v1\"` | |\n| `description` | yes | `string` | |\n| `publisher` | yes | `string` | Base URL of the default-catalog publisher. Each kind is published at `<publisher>/<kind>/index.json`. |\n| `source` | yes | `object` | The published catalog files the snapshot was taken from. |\n| `kinds` | yes | `object` | One entry per catalog kind, keyed by the kind's URL segment. |\n\n### Nested Types\n\n#### KindEntry\n\n- Type: `object`\n- Required fields: `mode`, `records`, `contentSha256`, `gallery`\n\n| Field | Required | Type | Notes |\n| --- | --- | --- | --- |\n| `mode` | yes | `enum:mirror` | Always 'mirror': the snapshot holds every record the publisher serves for the kind (OPF 0.15). |\n| `records` | yes | `integer` | Number of records bundled for this kind. |\n| `contentSha256` | yes | `string` | contentSha256 of the bundled records, as defined by the catalog index schema. |\n| `gallery` | yes | `object` | The published catalog for this kind at the pinned commit. |\n\n## Chart Type\n\n- File: `spec/schemas/chart-type.schema.json`\n- Schema id: `https://openpresentation.org/schema/opf-chart-type/v1`\n- Type: `object`\n- Required fields: `$schema`, `id`, `name`, `mappings`\n- Purpose: Schema for chart-type records: display metadata for chart pickers (name, group, expected series, complexity) and the Open XML mapping a picker can describe. One record per chart type that Aspose.Slides officially supports (see mappings.renderers[\"aspose-slides\"].chartType), plus the column-and-line combination 'combo'. A document's chart.type is an engine vocabulary validated directly; engines never look a chart-type record up. pptx.gallery publishes these records and @openpresentation/opf/ca...\n\n| Field | Required | Type | Notes |\n| --- | --- | --- | --- |\n| `$schema` | yes | `const:\"https://openpresentation.org/schema/opf-chart-type/v1\"` | Identifies this record as a chart type in the open presentation catalog. |\n| `id` | yes | `string` | The chart-type value (`chart.type`) this record describes. Lowercase kebab-case; it may start with a digit (e.g., '100pct-stacked-column') to mirror conventional chart naming. |\n| `name` | yes | `string` | Human-readable name of the chart type, as a chart picker shows it. |\n| `summary` | no | `string` | One-sentence positioning: when to reach for this chart variant. |\n| `description` | no | `string` | Longer prose describing the chart and ideal use cases. |\n| `mappings` | yes | `ref:ChartTypeMappings` | Canonical and optional renderer-specific mappings used by engines to render this chart type. |\n| `group` | no | `string` | Top-level grouping in the chart picker. |\n| `groupSort` | no | `integer` | Display ordering hint within the chart group. |\n| `complexity` | no | `enum:simple \\| calculated \\| hierarchical \\| normalized` | Shape of the underlying data: a flat series ('simple'), one with engine-side calculation ('calculated'), parent-child rows ('hierarchical'), or pre-normalized rows ('normalized'). The editor offers only 'simple' types... |\n| `series` | no | `integer` | Number of data series this chart type expects: exactly that many, except that a stacked or percent-stacked type and a combination (composition 'mixed', such as combo) expect at least that many. The editor offers a typ... |\n| `tags` | no | `array<string>` | Free-form labels for filtering and search. |\n| `preview` | no | `object` | Visual previews of the record, used by picker UIs and inline rendering. All sub-fields are optional; engines fall back gracefully when previews aren't available. |\n\n### Nested Types\n\n#### ChartTypeMappings\n\n- Type: `object`\n- Required fields: `openxml`\n\n| Field | Required | Type | Notes |\n| --- | --- | --- | --- |\n| `openxml` | yes | `ref:OpenXmlChartMapping` | Canonical mapping to Open XML chart structures. |\n| `renderers` | no | `object` | Optional renderer-specific mappings. Keys are renderer ids; values are intentionally opaque to OPF. The bundled catalog records the matching Aspose.Slides ChartType enumeration member under the \"aspose-slides\" key, e.... |\n\n#### OpenXmlChartMapping\n\n- Type: `object`\n- Required fields: none\n\n| Field | Required | Type | Notes |\n| --- | --- | --- | --- |\n| `element` | no | `string` | Primary Open XML chart element or extension chart element, such as 'barChart', 'lineChart', 'pieChart', 'treemapChart', or 'waterfallChart'. |\n| `barDir` | no | `enum:bar \\| col` | Bar direction for Open XML barChart mappings. |\n| `grouping` | no | `enum:standard \\| clustered \\| stacked \\| percentStacked` | Open XML chart grouping value when the chart family supports grouping. |\n| `marker` | no | `boolean` | Whether the chart type expects visible data markers. |\n| `radarStyle` | no | `enum:standard \\| marker \\| filled` | Open XML radarStyle value for radarChart mappings. |\n| `scatterStyle` | no | `enum:line \\| lineMarker \\| marker \\| smooth \\| smoothMarker` | Open XML scatterStyle value for scatterChart mappings. |\n| `composition` | no | `enum:single \\| mixed \\| extension` | Whether the chart maps to one standard chart element, multiple combined chart elements, or an Open XML extension chart. |\n| `extension` | no | `string` | Optional Open XML extension namespace or element hint for extension charts. |\n| `series` | no | `array<ref:OpenXmlChartMapping>` | Open XML chart elements used by mixed/composite chart types. |\n| `notes` | no | `string` | Short implementation note for mappings that need renderer interpretation. |\n\n## Color Scheme\n\n- File: `spec/schemas/color-scheme.schema.json`\n- Schema id: `https://openpresentation.org/schema/opf-color-scheme/v1`\n- Type: `object`\n- Required fields: `$schema`, `id`, `name`\n- Purpose: Schema for color-scheme records. Each scheme is a named palette with the twelve PowerPoint color slots (six accents, two darks, two lights, plus hyperlink and followed-hyperlink), suitable for being mapped directly into OOXML theme XML. Documents reference a scheme from design.colorScheme (or design.colorScheme.id) and from a theme's colorScheme; slot overrides on design.colorScheme take precedence over the resolved scheme. The slot fields here mirror the inline-override fields on the in-docu...\n\n| Field | Required | Type | Notes |\n| --- | --- | --- | --- |\n| `$schema` | yes | `const:\"https://openpresentation.org/schema/opf-color-scheme/v1\"` | Identifies a published record file as a colour scheme record. Required in a published file; an embedded copy omits it. |\n| `id` | yes | `string` | The colour scheme's id in its catalog, lowercase kebab-case. Required in a published record file; a document references the record by it (a bare id, or 'name:id' for a named group) and embeds it keyed by it, without t... |\n| `name` | yes | `string` | Human-readable scheme name shown in pickers. |\n| `summary` | no | `string` | One-sentence positioning of the palette what mood it evokes and where to use it. |\n| `description` | no | `string` | Longer prose describing the palette and its intended use. |\n| `accent1` | no | `ref:HexColor` | Accent 1 color (hex). Mirrors the OOXML accent1 slot. |\n| `accent2` | no | `ref:HexColor` | Accent 2 color (hex). Mirrors the OOXML accent2 slot. |\n| `accent3` | no | `ref:HexColor` | Accent 3 color (hex). Mirrors the OOXML accent3 slot. |\n| `accent4` | no | `ref:HexColor` | Accent 4 color (hex). Mirrors the OOXML accent4 slot. |\n| `accent5` | no | `ref:HexColor` | Accent 5 color (hex). Mirrors the OOXML accent5 slot. |\n| `accent6` | no | `ref:HexColor` | Accent 6 color (hex). Mirrors the OOXML accent6 slot. |\n| `dark1` | no | `ref:HexColor` | Dark 1 color (hex). Typically the deepest neutral; OOXML dark1. |\n| `dark2` | no | `ref:HexColor` | Dark 2 color (hex). Secondary dark; OOXML dark2. |\n| `light1` | no | `ref:HexColor` | Light 1 color (hex). Typically the slide canvas; OOXML lt1. |\n| `light2` | no | `ref:HexColor` | Light 2 color (hex). Secondary light surface; OOXML lt2. |\n| `hyperlink` | no | `ref:HexColor` | Hyperlink color (hex). OOXML hlink. |\n| `followedHyperlink` | no | `ref:HexColor` | Followed-hyperlink color (hex). OOXML folHlink. |\n| `tags` | no | `array<string>` | Free-form labels for filtering and search. |\n| `preview` | no | `object` | Visual previews of the record, used by picker UIs and inline rendering. All sub-fields are optional; engines fall back gracefully when previews aren't available. |\n\n### Nested Types\n\n#### HexColor\n\n- Type: `string`\n- Required fields: none\n- Purpose: Hex color: '#RGB', '#RRGGBB' or '#RRGGBBAA'. The same definition as HexColor in opf.schema.json.\n\n_No named properties._\n\n## Font Scheme\n\n- File: `spec/schemas/font-scheme.schema.json`\n- Schema id: `https://openpresentation.org/schema/opf-font-scheme/v1`\n- Type: `object`\n- Required fields: `$schema`, `id`, `name`, `major`, `minor`\n- Purpose: Schema for font-scheme records. Each scheme pairs a major (heading) and minor (body) font family in the OOXML majorFont/minorFont sense, scoped to a target app (powerpoint or google-slides) and a language family (Latin, East Asian, or Complex Script). Documents reference a scheme from design.fontScheme (or design.fontScheme.id), from a theme's fontScheme and from a language's fontScheme / googleFontScheme. Role overrides on design.fontScheme (heading, body, accent, code), each a font family n...\n\n| Field | Required | Type | Notes |\n| --- | --- | --- | --- |\n| `$schema` | yes | `const:\"https://openpresentation.org/schema/opf-font-scheme/v1\"` | Identifies a published record file as a font scheme record. Required in a published file; an embedded copy omits it. |\n| `id` | yes | `string` | The font scheme's id in its catalog, lowercase kebab-case. Required in a published record file; a document references the record by it (a bare id, or 'name:id' for a named group) and embeds it keyed by it, without thi... |\n| `name` | yes | `string` | Human-readable scheme name shown in pickers. |\n| `major` | yes | `string` | Heading (major) font family mirrors the OOXML majorFont entry. |\n| `minor` | yes | `string` | Body (minor) font family mirrors the OOXML minorFont entry. |\n| `code` | no | `string` | Optional monospaced font family name for code blocks and for inline code runs (TextRun.code). It has the same shape as the OPF FontScheme 'code' role, so a record and an inline design.fontScheme override are interchan... |\n| `eastAsian` | no | `object` | East Asian script fonts. Maps to the OOXML a:ea element of majorFont (major) and minorFont (minor), and to run-level a:ea. When set, they fill the eastAsian slot for every language; when omitted, the slot comes from t... |\n| `complexScript` | no | `object` | Complex-script fonts (for example Arabic, Hebrew, Indic and Thai). Maps to the OOXML a:cs element of majorFont (major) and minorFont (minor), and to run-level a:cs. When set, they fill the complexScript slot for every... |\n| `type` | no | `enum:sans-serif \\| serif \\| monospace` | High-level typographic class of the scheme. |\n| `app` | no | `enum:powerpoint \\| google-slides` | Target application this font pairing is intended for. Metadata for pickers and catalog filters: no engine changes its output by it. |\n| `languageFamily` | no | `enum:latin \\| ea \\| cs \\| eastAsian \\| complexScript` | Font-language family this scheme is intended for: 'latin' for Latin-script content, 'ea' (or 'eastAsian', the same value) for East Asian scripts, 'cs' (or 'complexScript', the same value) for Complex Scripts. The long... |\n| `languages` | no | `array<string>` | Optional list of BCP-47 language tags this scheme is curated for. Useful for picker UIs that group fonts by language coverage. As the design font scheme, an 'ea' or 'cs' scheme fills its script slot only when this lis... |\n| `textSample` | no | `string` | Short specimen string used by picker UIs to preview the scheme. |\n| `summary` | no | `string` | One-sentence positioning of the font pairing. |\n| `description` | no | `string` | Longer prose describing the font scheme and where it shines. |\n| `tags` | no | `array<string>` | Free-form labels for filtering and search. |\n| `preview` | no | `object` | Visual previews of the record, used by picker UIs and inline rendering. All sub-fields are optional; engines fall back gracefully when previews aren't available. |\n\n## Language\n\n- File: `spec/schemas/language.schema.json`\n- Schema id: `https://openpresentation.org/schema/opf-language/v1`\n- Type: `object`\n- Required fields: `$schema`, `id`, `name`, `bcp47`\n- Purpose: Schema for language records: display metadata for the language pickers of authoring tools (name, ISO code, the default font schemes a picker suggests). The document's 'language' is an engine vocabulary, a BCP-47 tag validated directly; engines never look a language record up, and their script, direction, OOXML tag and default script fonts come from spec/reference/engine-vocabularies.json. pptx.gallery publishes these records and @openpresentation/opf/catalog ships them as catalogDisplay.langu...\n\n| Field | Required | Type | Notes |\n| --- | --- | --- | --- |\n| `$schema` | yes | `const:\"https://openpresentation.org/schema/opf-language/v1\"` | Identifies this record as a language in the openpresentation.org catalog. |\n| `id` | yes | `string` | The record's id in the gallery's language catalog, lowercase kebab-case. Documents name a language by its BCP-47 tag ('bcp47'), never by this id. |\n| `name` | yes | `string` | Human-readable language name. |\n| `code` | no | `string` | ISO 639-3 (or 639-2) three-letter language code. Carried for engines that prefer ISO codes. |\n| `bcp47` | yes | `string` | BCP-47 language tag for this record. Use 'en-GB' for UK English; 'en-UK' is not a valid BCP-47 region form. |\n| `ooxmlLang` | no | `string` | Curated culture tag for OOXML text-run language attributes (a:rPr/@lang, a:endParaRPr/@lang), in the language-[Script-]REGION form Office recognizes (e.g. 'ja-JP', 'ar-SA', 'ms-MY', 'nb-NO', 'fil-PH', 'zh-CN'). Engine... |\n| `direction` | no | `enum:ltr \\| rtl` | Base text direction for the language. When omitted, engines derive it from the script: Arabic (Arab), Hebrew (Hebr), Syriac (Syrc), Thaana (Thaa), N'Ko (Nkoo), Adlam (Adlm), Samaritan (Samr), Mandaic (Mand) and Hanifi... |\n| `script` | no | `string` | ISO 15924 script code of the language's writing system. The script selects the OOXML font slot the language's text uses: East Asian scripts (Hans, Hant, Hani, Jpan, Kore, Hang, Hira, Kana, Bopo, Yiii) use the eastAsia... |\n| `fontScheme` | no | `string` | The font-scheme id a picker suggests for this language when targeting PowerPoint output. Display metadata: engines take a language's default script fonts from their own vocabulary. |\n| `googleFontScheme` | no | `string` | The font-scheme id a picker suggests for this language when targeting Google Slides output. Display metadata. |\n| `summary` | no | `string` | One-sentence note about coverage or font defaults. |\n| `description` | no | `string` | Longer prose describing the language record and any font-pairing rationale. |\n| `tags` | no | `array<string>` | Free-form labels for filtering and search. |\n| `preview` | no | `object` | Visual previews of the record, used by picker UIs and inline rendering. All sub-fields are optional; engines fall back gracefully when previews aren't available. |\n\n## Layout Preview Index\n\n- File: `spec/schemas/layout-preview-index.schema.json`\n- Schema id: `https://openpresentation.org/schema/opf-layout-preview-index/v1`\n- Type: `object`\n- Required fields: `$schema`, `version`, `description`, `records`\n- Purpose: Shape of `spec/previews/layouts/index.json`, the manifest for the vendored slide-archetype preview gallery under `spec/previews/layouts/`. Each record names a preview id, its self-contained HTML file, and the file's exact UTF-8 byte length. These preview ids are an archetype taxonomy (e.g. 'swot-analysis', 'org-chart') distinct from the structural layout catalog at spec/catalogs/layouts/ (e.g. 'title', 'chart-2x') see spec/README.md. This schema describes a repo-internal index file, not an OP...\n\n| Field | Required | Type | Notes |\n| --- | --- | --- | --- |\n| `$schema` | yes | `const:\"https://openpresentation.org/schema/opf-layout-preview-index/v1\"` | |\n| `version` | yes | `string` | Index format version, as a string. |\n| `description` | yes | `string` | Human-readable description of the preview gallery and its rendering conventions. |\n| `records` | yes | `array<ref:PreviewRecord>` | One entry per vendored preview HTML file. |\n\n### Nested Types\n\n#### PreviewRecord\n\n- Type: `object`\n- Required fields: `id`, `file`, `bytes`\n- Purpose: Summary of one vendored preview HTML file.\n\n| Field | Required | Type | Notes |\n| --- | --- | --- | --- |\n| `id` | yes | `string` | Slide-archetype preview id (e.g. 'swot-analysis', 'agenda', 'org-chart'). Does not correspond to a spec/catalogs/layouts/ record id. |\n| `file` | yes | `string` | HTML filename, relative to this index file's directory. |\n| `bytes` | yes | `integer` | Exact UTF-8 byte length of the referenced HTML file's contents. |\n\n## Slide Layout\n\n- File: `spec/schemas/layout.schema.json`\n- Schema id: `https://openpresentation.org/schema/opf-layout/v1`\n- Type: `object`\n- Required fields: `$schema`, `id`, `name`\n- Purpose: Schema for slide-layout records. Each record describes a semantic slide layout: what regions it exposes, what content kinds those regions are intended to hold, how they are arranged (composition) and its design hints. Documents reference a layout from Slide.layout; a slide without a layout, or whose layout resolves nowhere, composes automatically. A document references a record as a bare id or 'name:id' and resolves it in its own catalogs groups first, then in the catalog the host registered...\n\n| Field | Required | Type | Notes |\n| --- | --- | --- | --- |\n| `$schema` | yes | `const:\"https://openpresentation.org/schema/opf-layout/v1\"` | Identifies a published record file as a layout record. Required in a published file; an embedded copy omits it. |\n| `id` | yes | `string` | The layout's id in its catalog, lowercase kebab-case. Required in a published record file; a document references the record by it (a bare id, or 'name:id' for a named group) and embeds it keyed by it, without this field. |\n| `name` | yes | `string` | Human-readable layout name shown in layout pickers. |\n| `summary` | no | `string` | One-sentence positioning of the layout when to reach for it. |\n| `description` | no | `string` | Longer prose describing the layout structure and ideal use cases. |\n| `design` | no | `ref:DesignHints` | The layout's design hints, with the same keys and values as the deck's design and a slide's design (a slide overrides exactly what its layout sets, by the same name). An absent key means the layout has no opinion. Eve... |\n| `placeholders` | no | `array<ref:Placeholder>` | Ordered regions the layout exposes. This is the single source of truth for what the layout holds: the content kind, the number of body regions and whether it has a title, subtitle or tag are derived from it (layoutCon... |\n| `tags` | no | `array<string>` | Free-form labels for filtering and search. |\n| `preview` | no | `object` | Visual previews of the record, used by picker UIs and inline rendering. All sub-fields are optional; engines fall back gracefully when previews aren't available. |\n| `composition` | no | `ref:Composition` | |\n\n### Nested Types\n\n#### Placeholder\n\n- Type: `object`\n- Required fields: `type`\n- Purpose: A single region inside a slide layout. Title, subtitle, and tag placeholders bind to the corresponding Slide fields; every other placeholder describes the content kind that region holds. The array order in the surrounding 'placeholders' field preserves layout region order.\n\n| Field | Required | Type | Notes |\n| --- | --- | --- | --- |\n| `type` | yes | `enum:title \\| subtitle \\| tag \\| text \\| list \\| image \\| video \\| chart \\| table \\| code \\| metric \\| quote \\| timeline` | The content kind of the region. This is the one OPF content-kind vocabulary: the same words name a slide's payload fields (text, items, image, video, chart, table, code, metric, quote, timeline). 'title', 'subtitle' a... |\n| `placement` | no | `ref:ImagePlacement` | Image placeholders only. Bleeds the slide's image for this region to one slide edge, as an image block's placement does: the n-th image placeholder places the slide's n-th top-level image (Slide.image or an image bloc... |\n\n#### ImagePlacement\n\n- Type: `object`\n- Required fields: `edge`\n- Purpose: The same object as ImagePlacement in opf.schema.json: a band along one slide edge for the image, edge to edge, with the rest of the slide left to the headings and the other content.\n\n| Field | Required | Type | Notes |\n| --- | --- | --- | --- |\n| `edge` | yes | `enum:left \\| right \\| top \\| bottom` | The slide edge the image bleeds to. In a right-to-left deck 'left' is the start side, drawn at the right. |\n| `size` | no | `number` | Share of the slide width (left, right) or height (top, bottom) given to the band. Default 0.5. |\n| `inset` | no | `boolean` | Draw the frame inside the slide padding on every side of the band instead of edge to edge. Default false. |\n\n#### DesignHints\n\n- Type: `object`\n- Required fields: none\n- Purpose: Design hints a layout carries. The keys and lowercase values are those of the deck's design and a slide's design in opf.schema.json (Design), so a slide overrides a layout by the same name and layout.design can be copied into slide.design unchanged. An absent key means the layout has no opinion. Only the keys the layout sets are stored. scripts/check-spec-integrity.mjs verifies that every key here has the same typ...\n\n| Field | Required | Type | Notes |\n| --- | --- | --- | --- |\n| `titleAlignment` | no | `enum:left \\| center \\| right` | Horizontal alignment of the title placeholder. |\n| `contentAlignment` | no | `enum:left \\| center \\| right` | Horizontal alignment of the body regions. |\n| `contentBox` | no | `boolean` | Whether the body regions are drawn inside a visible card or surface. |\n| `contentDirection` | no | `enum:horizontal \\| vertical` | Axis along which parallel body content is arranged: 'vertical' is a column, 'horizontal' a row. Composition applies it when neither the slide nor this layout's composition sets a mode, below the slide's and the deck's... |\n| `chartPrimary` | no | `enum:none \\| top \\| bottom \\| left \\| right` | Where the primary chart sits relative to the other body content. Composition applies it below the slide's and the deck's design.chartPrimary, with the semantics described there. |\n| `imageFit` | no | `enum:cover \\| contain \\| stretch` | Default fit of the slide's image blocks: 'cover' fills the frame and crops around focus, 'contain' shows the whole picture, 'stretch' scales it to the frame. |\n| `listBullet` | no | `enum:character \\| image` | Marker style of lists: 'character' draws the glyph, 'image' draws the deck's icon logo as a picture bullet. |\n\n#### Composition\n\n- Type: `object`\n- Required fields: none\n- Purpose: Portable dynamic composition. Slide fields override the resolved layout. Nested groups arrange their children independently, inheriting only minFontSize and overflow. Explicit promoted regions retain their positions.\n\n| Field | Required | Type | Notes |\n| --- | --- | --- | --- |\n| `mode` | no | `enum:auto \\| grid \\| row \\| column` | auto chooses a grid from available space and content; grid uses columns; row and column use one horizontal or vertical track. |\n| `columns` | no | `integer` | Column count for grid. In auto mode this caps the number of columns. |\n| `gap` | no | `number` | Space between cells as a fraction of the container short edge (canvas at slide root). Default 0.03333333333333333. |\n| `padding` | no | `number` | Inset as a fraction of the container short edge. Default 0.08 on a slide, 0 inside a group. |\n| `weights` | no | `array<number>` | Relative track sizes: columns for row/grid/auto, rows for column. Omitted tracks have weight 1; extra weights are ignored. |\n| `minFontSize` | no | `number` | Minimum readable text size in reference pixels at a 720-pixel canvas short edge. Default 16. Overflow is diagnosed when text cannot fit at this size. |\n| `overflow` | no | `enum:warn \\| error` | warn returns diagnostics for content that does not fit; error rejects layout. Content is never silently removed. Default warn. |\n\n## Narrative Template\n\n- File: `spec/schemas/narrative.schema.json`\n- Schema id: `https://openpresentation.org/schema/opf-narrative/v1`\n- Type: `object`\n- Required fields: `$schema`, `id`, `name`, `beats`\n- Purpose: Schema for narrative records. A narrative is a plan: a named story arc (e.g. 'problem-solution', 'scqa') as an ordered list of beats, each saying what its slide must do. A document points at one with the string 'narrative' and links its slides to beats with 'slides[].beat'; a narrative the document defines itself is a record in catalogs.custom.narratives. Core validate warns about a 'slides[].beat' id the resolved narrative does not define (opf/unknown-beat) and about a root 'duration' outsid...\n\n| Field | Required | Type | Notes |\n| --- | --- | --- | --- |\n| `$schema` | yes | `const:\"https://openpresentation.org/schema/opf-narrative/v1\"` | Identifies a published record file as a narrative record. Required in a published file; an embedded copy omits it. |\n| `id` | yes | `string` | The narrative's id in its catalog, lowercase kebab-case. Required in a published record file; a document references the record by it (a bare id, or 'name:id' for a named group) and embeds it keyed by it, without this... |\n| `name` | yes | `string` | Human-readable template name, e.g. 'Problem Solution'. |\n| `summary` | no | `string` | One-sentence description of when and why to use this narrative. |\n| `description` | no | `string` | Longer prose describing the narrative arc and ideal use cases. Used by AI-driven generation to seed deck-level direction. |\n| `audienceFit` | no | `array<string>` | Audiences this narrative works well for, e.g. ['executive', 'investor', 'customer']. |\n| `duration` | no | `object` | Typical talk length this narrative suits, as a range in minutes. A deck's own target is the root 'duration' (one number); core validate warns when that target lies outside this range, and when 'min' is greater than 'm... |\n| `tags` | no | `array<string>` | Free-form labels for filtering and search, e.g. ['business', 'pitch', 'internal']. |\n| `preview` | no | `object` | Visual previews of the record, used by picker UIs and inline rendering. All sub-fields are optional; engines fall back gracefully when previews aren't available. |\n| `beats` | yes | `array<ref:Beat>` | Ordered list of beats that make up the narrative arc. |\n\n### Nested Types\n\n#### Beat\n\n- Type: `object`\n- Required fields: `id`, `name`\n- Purpose: A single narrative beat a labeled segment of the story arc with a specific dramatic purpose. One beat is one slide: 'type' is that slide's Slide.type and 'layout' is its Slide.layout, so a planner copies them straight into a skeleton deck. Split a heavy beat into several beats rather than giving it several slides.\n\n| Field | Required | Type | Notes |\n| --- | --- | --- | --- |\n| `id` | yes | `string` | Stable slug used by Slide.beat to reference this beat. Lowercase kebab-case. |\n| `name` | yes | `string` | Human-readable beat name, e.g. 'The Problem'. |\n| `description` | no | `string` | Curator-written prose that explains what this beat should accomplish. |\n| `instructions` | no | `string` | Short author-facing instruction for the beat typically one phrase. Complements 'description' with a concise directive. |\n| `type` | no | `enum:text \\| list \\| image \\| video \\| chart \\| table \\| code \\| metric \\| quote \\| timeline` | Default content kind for the beat's slide: the same values as Slide.type. Helps engines and planners choose a layout when only the beat is known. |\n| `layout` | no | `string` | Suggested layout id for the beat's slide, e.g. 'section-divider', 'title-slide', 'text-left'. Resolves the same way as Slide.layout against catalogs.layouts and the default catalog at https://www.pptx.gallery/layouts. |\n| `thoughtCues` | no | `array<string>` | Optional speaker or thinking cues for the beat: questions the slide should answer. A planner or author turns them into the slide's notes; no engine copies them there on its own. |\n\n## Purpose\n\n- File: `spec/schemas/purpose.schema.json`\n- Schema id: `https://openpresentation.org/schema/opf-purpose/v1`\n- Type: `object`\n- Required fields: `$schema`, `id`, `name`\n- Purpose: Schema for purpose records. Each record names a presentation objective such as informing, aligning, persuading, driving a decision, or selling. Documents reference a purpose from 'purpose', which also accepts free-form text and inline Purpose objects. A document references a record as a bare id or 'name:id' and resolves it in its own catalogs groups first, then in the catalog the host registered for the group's source; engines never fetch a catalog. A published record file carries '$schema' a...\n\n| Field | Required | Type | Notes |\n| --- | --- | --- | --- |\n| `$schema` | yes | `const:\"https://openpresentation.org/schema/opf-purpose/v1\"` | Identifies a published record file as a purpose record. Required in a published file; an embedded copy omits it. |\n| `id` | yes | `string` | The purpose's id in its catalog, lowercase kebab-case. Required in a published record file; a document references the record by it (a bare id, or 'name:id' for a named group) and embeds it keyed by it, without this fi... |\n| `name` | yes | `string` | Human-readable purpose name shown in pickers. |\n| `summary` | no | `string` | One-sentence positioning of the purpose what this deck is trying to accomplish. |\n| `description` | no | `string` | Longer prose describing when to use this purpose and how it should shape a deck. |\n| `outcome` | no | `string` | Desired audience outcome after the presentation. |\n| `successCriteria` | no | `array<string>` | Observable signals that the deck accomplished this purpose. |\n| `recommendedNarratives` | no | `array<string>` | Soft cross-link: ids of narratives in the same catalog that work well for this purpose. Never resolved, embedded or validated as references. |\n| `recommendedTones` | no | `array<string>` | Soft cross-link: ids of tones in the same catalog that work well for this purpose. Never resolved, embedded or validated as references. |\n| `tags` | no | `array<string>` | Free-form labels for filtering and search. |\n| `preview` | no | `object` | Visual previews of the record, used by picker UIs and inline rendering. All sub-fields are optional. |\n\n## Social Platform\n\n- File: `spec/schemas/social-platform.schema.json`\n- Schema id: `https://openpresentation.org/schema/opf-social-platform/v1`\n- Type: `object`\n- Required fields: `$schema`, `id`, `name`\n- Purpose: Schema for social-platform records: display metadata for authoring UIs (name, brand color, handle example) alongside the URL patterns. The keys of a Socials object are an engine vocabulary validated directly; engines link a handle with the patterns in spec/reference/engine-vocabularies.json and never look a social-platform record up. pptx.gallery publishes these records and @openpresentation/opf/catalog ships them as catalogDisplay.socialPlatforms.\n\n| Field | Required | Type | Notes |\n| --- | --- | --- | --- |\n| `$schema` | yes | `const:\"https://openpresentation.org/schema/opf-social-platform/v1\"` | Identifies this record as a social-platform entry in the openpresentation.org catalog. |\n| `id` | yes | `string` | The social platform's key, as written in a Socials object. Lowercase kebab-case. |\n| `name` | yes | `string` | Human-readable platform name shown in pickers and footers. |\n| `summary` | no | `string` | One-sentence positioning of the platform what it's used for and who's on it. |\n| `description` | no | `string` | Longer prose describing the platform and any rendering conventions (e.g., handle prefixes, distributed instances). |\n| `baseUrl` | no | `string` | Canonical base URL of the platform used as the prefix when normalizing handles to full URLs. |\n| `profileUrlPattern` | no | `string` | URL pattern for individual member profiles. Use '{handle}' as the placeholder for the handle (with the prefix already stripped). |\n| `companyUrlPattern` | no | `string` | Optional URL pattern for organization / company pages, when the platform distinguishes them from member profiles. Use '{handle}' as the placeholder. |\n| `handlePrefix` | no | `string` | Conventional prefix character displayed before the handle (e.g. '@' for X / Mastodon / Threads / TikTok). Empty string when no prefix is used. Renderers strip it before substituting into URL patterns. |\n| `handleExample` | no | `string` | Example handle in its conventional rendered form, used by picker UIs and validation hints. |\n| `brandColor` | no | `string` | Brand color (hex) for branded icon chips, link styling, or section accents in authoring UIs. Catalog metadata: engines do not draw it. |\n| `tags` | no | `array<string>` | Free-form labels for filtering and search. |\n| `preview` | no | `object` | Visual previews of the record, used by picker UIs and inline rendering. All sub-fields are optional; engines fall back gracefully when previews aren't available. |\n\n## Theme\n\n- File: `spec/schemas/theme.schema.json`\n- Schema id: `https://openpresentation.org/schema/opf-theme/v1`\n- Type: `object`\n- Required fields: `$schema`, `id`, `name`\n- Purpose: Schema for theme records. Each theme is a small, named bundle that pairs a color scheme, a font scheme, a default theme-controlled background, and a slide size. Documents reference a theme from design.theme; design.colorScheme, design.fontScheme, design.background and design.dimensions take precedence over the resolved theme. The theme's own colorScheme and fontScheme references resolve in the theme's group first, so a catalog's theme finds that catalog's schemes. A document references a reco...\n\n| Field | Required | Type | Notes |\n| --- | --- | --- | --- |\n| `$schema` | yes | `const:\"https://openpresentation.org/schema/opf-theme/v1\"` | Identifies a published record file as a theme record. Required in a published file; an embedded copy omits it. |\n| `id` | yes | `string` | The theme's id in its catalog, lowercase kebab-case. Required in a published record file; a document references the record by it (a bare id, or 'name:id' for a named group) and embeds it keyed by it, without this field. |\n| `name` | yes | `string` | Human-readable theme name shown in pickers. |\n| `summary` | no | `string` | One-sentence positioning of the theme when to reach for it. |\n| `description` | no | `string` | Longer prose describing what the theme looks and feels like and the kinds of decks it suits. |\n| `colorScheme` | no | `string` | The theme's default colour scheme: a reference (a bare id or 'name:id') resolved like design.colorScheme, but in the theme's own catalogs group first. |\n| `fontScheme` | no | `string` | The theme's default font scheme: a reference (a bare id or 'name:id') resolved like design.fontScheme, but in the theme's own catalogs group first. |\n| `background` | no | `ref:ThemeBackground` | |\n| `dimensions` | no | `enum:16:9 \\| 4:3 \\| 16:10 \\| 1:1 \\| 4:5 \\| 9:16 \\| letter \\| a4 \\| widescreen \\| standard` | Default slide size for this theme. Accepts the same preset values as design.dimensions.preset. |\n| `tags` | no | `array<string>` | Free-form labels for filtering and search. |\n| `preview` | no | `object` | Visual previews of the record, used by picker UIs and inline rendering. All sub-fields are optional; engines fall back gracefully when previews aren't available. |\n\n### Nested Types\n\n#### ThemeBackgroundSlot\n\n- Type: `enum:light1 | light2 | dark1 | dark2`\n- Required fields: none\n- Purpose: PowerPoint theme-controlled slide background slot from the active color scheme. These are slots, not assumptions about actual colors: light1 is usually white and dark1 is usually black by convention, but the color scheme controls the real values.\n\n_No named properties._\n\n#### ThemeBackground\n\n- Type: `object`\n- Required fields: `type`, `slot`\n- Purpose: Theme-controlled PowerPoint slide background. The slot is resolved through the active color scheme and remains theme-aware.\n\n| Field | Required | Type | Notes |\n| --- | --- | --- | --- |\n| `type` | yes | `const:\"theme\"` | Theme-controlled background fill. |\n| `slot` | yes | `ref:ThemeBackgroundSlot` | |\n\n## Tone\n\n- File: `spec/schemas/tone.schema.json`\n- Schema id: `https://openpresentation.org/schema/opf-tone/v1`\n- Type: `object`\n- Required fields: `$schema`, `id`, `name`\n- Purpose: Schema for tone records. Each record names a presentation tone (e.g. 'formal', 'casual', 'inspirational') and carries voice cues, anti-patterns, and sample phrases that AI-driven generation uses to shape output. Documents reference a tone from 'tone'. A document references a record as a bare id or 'name:id' and resolves it in its own catalogs groups first, then in the catalog the host registered for the group's source; engines never fetch a catalog. A published record file carries '$schema' a...\n\n| Field | Required | Type | Notes |\n| --- | --- | --- | --- |\n| `$schema` | yes | `const:\"https://openpresentation.org/schema/opf-tone/v1\"` | Identifies a published record file as a tone record. Required in a published file; an embedded copy omits it. |\n| `id` | yes | `string` | The tone's id in its catalog, lowercase kebab-case. Required in a published record file; a document references the record by it (a bare id, or 'name:id' for a named group) and embeds it keyed by it, without this field. |\n| `name` | yes | `string` | Human-readable tone name shown in pickers. |\n| `summary` | no | `string` | One-sentence positioning of the tone when to reach for it. |\n| `description` | no | `string` | Longer prose describing the tone and the kinds of decks it suits. |\n| `voiceCues` | no | `array<string>` | Short directives that shape AI generation toward this tone. Phrased as imperatives, e.g. 'use second-person', 'favor short sentences', 'lead with the recommendation'. |\n| `avoid` | no | `array<string>` | Anti-patterns that AI generation should not produce when this tone is active. |\n| `samplePhrases` | no | `array<string>` | Short example phrases that exemplify this tone. Used by picker UIs and as few-shot examples for AI generation. |\n| `recommendedNarratives` | no | `array<string>` | Soft cross-link: ids of narratives in the same catalog this tone pairs well with. Used by picker UIs to suggest narratives once a tone is chosen. Never resolved, embedded or validated as references. |\n| `tags` | no | `array<string>` | Free-form labels for filtering and search. |\n| `preview` | no | `object` | Visual previews of the record, used by picker UIs and inline rendering. All sub-fields are optional. |\n"
14
14
  },
15
15
  {
16
16
  "slug": "chart-options",
@@ -28,13 +28,13 @@ var docsData = Object.freeze([
28
28
  "slug": "cli",
29
29
  "file": "docs/cli.md",
30
30
  "title": "The `opf` CLI: producing and reading files",
31
- "markdown": "# The `opf` CLI: producing and reading files\n\nThe CLI (`@openpresentation/cli`, binary `opf`, Node 24) validates, edits, paginates and bundles documents (see\n[its README](../packages/cli/README.md)). `opf validate` is the one checker ([validate](validate.md)). Three commands\nproduce and read files: `opf render`, `opf export` and `opf import`. They are in the CLI after RR-27 of the [release readiness program](programs/release-readiness/README.md)\nand ship in CLI 0.10.0, the first CLI release after 0.9.2.\n\nAll three are deterministic and local: no network, no model, no telemetry, no system fonts. The same document, options\nand installed package versions give the same bytes on every operating system.\n\n## Install\n\n`render` and `export` need `@openpresentation/opf-render`; `export --format pptx` and `import` also need\n`@openpresentation/opf-pptx`. Both are **optional peer dependencies** of the CLI (decision RR-27, below), loaded the\nfirst time a command needs them. Install them next to the CLI:\n\n```sh\nnpm install -g @openpresentation/cli @openpresentation/opf-render @openpresentation/opf-pptx\n# a project that depends on the CLI\nnpm install -D @openpresentation/cli @openpresentation/opf-render @openpresentation/opf-pptx\n# one run, nothing installed\nnpx -p @openpresentation/cli -p @openpresentation/opf-render -p @openpresentation/opf-pptx opf export deck.opf.json --format pptx\n```\n\nThe CLI looks for a peer beside itself first (a global install, an npx run, a project dependency) and in the working\ndirectory second. Without it the command exits 2 with `code: \"peer-not-installed\"` and the install command. The\ncommands check the functions they call and name the version to install when an older peer lacks one.\n\n## `opf render`\n\n```sh\nopf render deck.opf.json [--slides 1,3-5] [--include-hidden] [--format svg|png] [--scale N] [--out dir|file|-]\n```\n\nOne file per slide: `<name>-001.svg` (or `.png`) in `--out` (default `<name>-slides/`). See [Output names](#output-names) for `<name>`.\nSlides marked `hidden: true` are skipped, as in the presenter, unless `--include-hidden`; the numbers in file names stay the\nslide numbers of the document, so skipping slide 2 writes `-001` and `-003`. `--slides` takes one-based\nnumbers and ranges (`1,3-5`, `2-` to the end, `-3` from the start). `--format` defaults to `svg`. `--scale` (0.1 to 8,\ndefault 1) sets the PNG pixel density against the 1280 x 720 reference slide. `--out -` writes one slide to stdout.\n\nAn SVG is standalone: it embeds the faces its text names (a Latin slide carries about 2 MB of font data), never the\nwhole pack. `--svg-fonts none` leaves the fonts out for a smaller file that depends on the viewer's fonts. PNG and PDF\noutput reads the same font files and embeds nothing.\n\n## `opf export`\n\n```sh\nopf export deck.opf.json --format pptx|pdf|png|svg [--out file|dir|.zip|-] [--slides 1,3-5] [--include-hidden]\n [--pdf-mode vector|raster] [--chartex auto|native|fallback]\n [--provenance full|references-only|none] [--image-format compatible|preserve]\n```\n\n| Format | Output | Notes |\n| --- | --- | --- |\n| `pptx` | `<name>.pptx` | opf-pptx `toPptx`. The whole deck (`--slides` is refused). `--chartex`, `--provenance` and `--image-format` are the `toPptx` options of the same names (`none` writes no provenance tags). |\n| `pdf` | `<name>.pdf` | One page per selected slide. `--pdf-mode` picks `vector` (selectable text, from the opf-render release that carries opf-render#90) or `raster` (one image per page); omitted, the installed renderer's default. `--pdf-mode vector` on a renderer without it exits 2. The report states the mode used (`pdf.mode`). |\n| `png`, `svg` | a directory, one file (`--out x.png`, one slide), or a zip (`--out x.zip`) | As `render`. Zip entries are stored (not deflated) with a fixed timestamp, so the archive is byte-identical everywhere. |\n\nThe format is taken from `--out`'s extension when `--format` is omitted (`.pptx`, `.pdf`, `.png`, `.svg`).\n\nThe PDF and the `png` and `svg` outputs skip hidden slides unless `--include-hidden`. The `pptx` output always carries them, as hidden slides (`--include-hidden` is refused for it).\n\n## `opf import`\n\n```sh\nopf import deck.pptx [--out deck.opf.json|-] [--signals signals.json]\n```\n\n`fromPptx` to an OPF document (default `<name>.opf.json` in the working directory; `-` reads or writes stdin/stdout).\nImport is a conversion, not a lossless round trip for arbitrary decks: what it cannot keep is reported as `import/...`\ndiagnostics. `--signals` also writes the raw per-shape layout and style signals (`fromPptx` with `signals: true`,\nopf-pptx 0.11.9 and later; an older peer exits 2). The signals are deterministic data; they never leave the machine.\nAI reconstruction of third-party decks is not part of the CLI (it lives in pptx.dev).\n\n## Options shared by `render` and `export`\n\n### Output names\n\nFiles are named by the deck: its root `filename` (a trailing `.pptx`, `.pdf`, `.png` or `.svg` is dropped, in any case), else\nits `name` slugified (`Q4 Review / 2026` becomes `Q4-Review-2026`), else the input file's stem (`deck.opf.json` gives `deck`).\nThe same rule names the editor's downloads. An explicit `--out` is used as given.\n\n### Hidden slides\n\n`render` and `export` skip slides marked `hidden: true` when they write one file per slide, a zip or a PDF, because a hidden slide is\nnot part of the presented sequence. `--include-hidden` writes them too. Slides named with `--slides` are always written, hidden or\nnot. The report lists the slide numbers left out in `skippedHidden`. A deck whose every slide is hidden exits 1 and names the flag.\n\n\n| Option | Meaning |\n| --- | --- |\n| `--include-hidden` | Write hidden slides too (per-slide images, zips and PDF). See [Hidden slides](#hidden-slides). |\n| `--paginate` | Paginate with the same fonts first (as `opf paginate` does, but measured), so overflowing slides split instead of reporting `text-overflow`. `--slides` then counts the paginated slides. |\n| `--date YYYY-MM-DD` | The date for `date: true` header and footer fields. The CLI never reads a clock; without it a current date is reported as unresolved. |\n| `--font-dir <directory>` (repeatable) | Your own `.ttf`/`.otf` files, loaded in addition to the bundled pack, directly inside the directory, sorted by name. A face that repeats a bundled family, weight and style is refused. |\n| `--asset-dir <directory>` | The folder relative image paths resolve against and the only folder read. Default: the document's folder (the working directory for stdin). |\n| `--fail-on <error\\|warning\\|info>` | Findings at or above this severity fail, and nothing is written (default `error`). `--fail-on warning` fails on warnings; every command that checks a document takes it. |\n| `--force` | Replace existing outputs. Without it any existing destination exits 1 before anything is written. |\n| `--json` | Accepted for scripts that pass it everywhere. Reports are always JSON; this is the default. |\n\n## Fonts\n\nThe renderer's bundled open font pack (the office pack: Carlito, Intos for Aptos, the open families font schemes\nselect and the open replacements the font policy routes proprietary families to, with lazy faces) with the visual\nsubstitution policy, plus the files from `--font-dir`. **System fonts are never loaded**, and nothing is downloaded.\nFont substitutions are listed under `fonts.substitutions` in the report with their `compatibility` (`metric` or\n`visual`). The PPTX keeps the font names the document chose.\n\nScripts beyond Latin, Greek and Cyrillic need the optional Noto script packages of the renderer\n(`@expo-google-fonts/noto-sans-jp` and so on). Install the ones named in the `fonts/script-font-not-installed`\ndiagnostic next to the CLI; without them, text the loaded faces cannot draw is the error `render/missing-glyph`.\n\n## Images and assets\n\nRelative image paths and `file:` paths resolve against the document's folder (or `--asset-dir`). Only `.png`, `.jpg`,\n`.jpeg`, `.gif`, `.webp` and `.svg` files whose content matches are read, and only inside that folder (symlinks are\nresolved first), so a document cannot pull another file on the machine into an output. URLs are never fetched. For SVG\nand PNG output, an unreadable image draws the renderer's placeholder with an `unresolved-asset` or `cli/asset-blocked`\nwarning. For PPTX it stops the export (`pptx/asset-unresolved`): opf-pptx would otherwise read the path itself. SVG\npictures in a PPTX get their PNG fallback from the CLI's own opf-render install (`svgRasterizer`), so they export\nwhichever way the packages were installed.\n\n## Markdown and YAML\n\nTwo more groups of commands convert between a deck and text, with no renderer: they are core features (`@openpresentation/opf/markdown` and `@openpresentation/opf/yaml`) and need no optional peer.\n\n```sh\nopf from-md deck.md deck.opf.json # Markdown in the OPF dialect to a validated deck\nopf to-md deck.opf.json deck.md # a deck as Markdown that reads back unchanged\nopf from-yaml deck.opf.yaml deck.opf.json # strict YAML to a validated deck (--aliases expands anchors)\nopf to-yaml deck.opf.json deck.opf.yaml --schema-comment\nopf validate deck.opf.yaml # every command that reads a deck reads .yaml and .yml files\nopf edit deck.opf.json --patch changes.json --output deck.opf.yaml # or --format yaml\nopf validate - --input-format yaml < deck.txt\n```\n\n- `from-md` and `to-md` follow the Markdown dialect (YAML front matter, `---` between slides, `#` title, lists, tables, `chart`, `metric` and `timeline` fences, speaker notes): see [Markdown and outlines](markdown.md). `from-md --format yaml` or a `.yaml` output writes the deck as YAML.\n- `from-yaml` and `to-yaml` read and write a deck as JSON-compatible YAML 1.2, with canonical key order and an optional `# yaml-language-server` line for editor validation: see [OPF as YAML](yaml.md). A file ending `.yaml` or `.yml` is read as YAML by every command; stdin and other names are JSON unless `--input-format yaml` is given. Commands that write a deck write YAML for an output ending `.yaml`/`.yml` or with `--format yaml`. A YAML syntax error exits 2 with the line and column (`opf validate` reports it as a `yaml/<rule>` finding and exits 1); commands that rewrite a YAML file do not preserve its comments and say so on stderr.\n- All of them print JSON reports (on stderr when stdout carries the document) and use the exit codes below: 0 success, 1 invalid content, an output conflict or a finding at or above `--fail-on`, 2 usage, a read error or I/O.\n\n## Diagnostics and exit codes\n\nThe report is the [`opf validate`](validate.md) report with the written files added: `ok`, `valid`, `schemaValid`,\n`findings` (`ruleId`, `severity`, `category`, JSON Pointer `path`, `scope`, `message`, `help`), `counts`, `checks`, `sha256` and\n`opfVersion`. The document's format and references are checked first (not accessibility or layout rules: a contrast\nwarning never blocks a write); an invalid one exits 1 and nothing is rendered. Library findings are added with the\nprefix `render/`, `pptx/`, `pdf/`, `fonts/`, `import/` or `cli/` (the same word is their category), for example\n`render/text-overflow`. Notes (`font-glyph-fallback`, `pdf-font-embedded`, `svg-sanitized`) are `info`; everything else a\nlibrary reports is a `warning`; a failed render, export or import is an `error`. `checks.layout` is `measured`: the\nCLI measures text with the fonts it loaded.\n\n`outputs` lists each file with `sha256`, `bytes`, `mediaType`, and for slides `slide`, `id`, `width`, `height`.\n`renderer` and `pptx` give the package versions used. With `--out -` the file goes to stdout and the report to stderr.\n\nExit `0`: success (warnings allowed). Exit `1`: invalid document, error finding, a finding at or above `--fail-on`, or an\nexisting output. Exit `2`: usage, I/O, a missing or too-old peer, a font directory problem. (`opf validate` has the\nsame codes; text that is not valid JSON is an invalid document, exit `1`, with an `opf/json-syntax` finding.)\n\n## Decisions (RR-27, vetoable)\n\n- **Peers, not bundled.** opf-render and opf-pptx are optional peer dependencies loaded lazily. opf-pptx pulls the\n native `sharp` engine, opf-render `resvg`, `fontkit` and the font packs (about 135 MB installed); bundling them would\n turn a 1.6 MB, dependency-free CLI into one that cannot be installed offline or on a locked-down agent host, and\n validating or editing a document would pay for it. The tarball stays small and `dependencies` stays empty.\n- **Reports are always JSON; `--json` is a no-op alias.** The CLI contract is JSON reports and JSON errors; a second\n text reporter would split every consumer.\n- **No clock.** `--date` is explicit so a rerun tomorrow gives the same bytes.\n- **Fonts are the bundled pack plus `--font-dir`.** Never system fonts (owner font policy).\n- **Stored zip entries.** Deflate output differs between zlib builds, which would make archive hashes host-dependent.\n\n## Follow-up (openpresentation.org)\n\nThe site's CLI and developer docs should gain a \"Render, export and import\" page from this file (install block,\nthe three commands, the report fields), and its quickstart should stop saying the CLI does not render. No public\nsupport or progress status goes on the site (program invariant); the page documents commands, not coverage.\n"
31
+ "markdown": "# The `opf` CLI: producing and reading files\n\nThe CLI (`@openpresentation/cli`, binary `opf`, Node 24) validates, edits, paginates and bundles documents (see\n[its README](../packages/cli/README.md)). `opf validate` is the one checker ([validate](validate.md)). Three commands\nproduce and read files: `opf render`, `opf export` and `opf import`. They are in the CLI after RR-27 of the [release readiness program](programs/release-readiness/README.md)\nand ship in CLI 0.10.0, the first CLI release after 0.9.2.\n\nAll three are deterministic and local: no network, no model, no telemetry, no system fonts. The same document, options\nand installed package versions give the same bytes on every operating system.\n\n## Install\n\n`render` and `export` need `@openpresentation/opf-render`; `export --format pptx` and `import` also need\n`@openpresentation/opf-pptx`. Both are **optional peer dependencies** of the CLI (decision RR-27, below), loaded the\nfirst time a command needs them. Install them next to the CLI:\n\n```sh\nnpm install -g @openpresentation/cli @openpresentation/opf-render @openpresentation/opf-pptx\n# a project that depends on the CLI\nnpm install -D @openpresentation/cli @openpresentation/opf-render @openpresentation/opf-pptx\n# one run, nothing installed\nnpx -p @openpresentation/cli -p @openpresentation/opf-render -p @openpresentation/opf-pptx opf export deck.opf.json --format pptx\n```\n\nThe CLI looks for a peer beside itself first (a global install, an npx run, a project dependency) and in the working\ndirectory second. Without it the command exits 2 with `code: \"peer-not-installed\"` and the install command. The\ncommands check the functions they call and name the version to install when an older peer lacks one.\n\n## `opf render`\n\n```sh\nopf render deck.opf.json [--slides 1,3-5] [--include-hidden] [--format svg|png] [--scale N] [--out dir|file|-]\n```\n\nOne file per slide: `<name>-001.svg` (or `.png`) in `--out` (default `<name>-slides/`). See [Output names](#output-names) for `<name>`.\nSlides marked `hidden: true` are skipped, as in the presenter, unless `--include-hidden`; the numbers in file names stay the\nslide numbers of the document, so skipping slide 2 writes `-001` and `-003`. `--slides` takes one-based\nnumbers and ranges (`1,3-5`, `2-` to the end, `-3` from the start). `--format` defaults to `svg`. `--scale` (0.1 to 8,\ndefault 1) sets the PNG pixel density against the 1280 x 720 reference slide. `--out -` writes one slide to stdout.\n\nAn SVG is standalone: it embeds the faces its text names (a Latin slide carries about 2 MB of font data), never the\nwhole pack. `--svg-fonts none` leaves the fonts out for a smaller file that depends on the viewer's fonts. PNG and PDF\noutput reads the same font files and embeds nothing.\n\n## `opf export`\n\n```sh\nopf export deck.opf.json --format pptx|pdf|png|svg [--out file|dir|.zip|-] [--slides 1,3-5] [--include-hidden]\n [--pdf-mode vector|raster] [--chartex auto|native|fallback]\n [--provenance full|references-only|none] [--image-format compatible|preserve]\n```\n\n| Format | Output | Notes |\n| --- | --- | --- |\n| `pptx` | `<name>.pptx` | opf-pptx `toPptx`. The whole deck (`--slides` is refused). `--chartex`, `--provenance` and `--image-format` are the `toPptx` options of the same names (`none` writes no provenance tags). |\n| `pdf` | `<name>.pdf` | One page per selected slide. `--pdf-mode` picks `vector` (selectable text, the default) or `raster` (one image per page). The report states the mode used (`pdf.mode`). |\n| `png`, `svg` | a directory, one file (`--out x.png`, one slide), or a zip (`--out x.zip`) | As `render`. Zip entries are stored (not deflated) with a fixed timestamp, so the archive is byte-identical everywhere. |\n\nThe format is taken from `--out`'s extension when `--format` is omitted (`.pptx`, `.pdf`, `.png`, `.svg`).\n\nThe PDF and the `png` and `svg` outputs skip hidden slides unless `--include-hidden`. The `pptx` output always carries them, as hidden slides (`--include-hidden` is refused for it).\n\n## `opf import`\n\n```sh\nopf import deck.pptx [--out deck.opf.json|-] [--signals signals.json]\n```\n\n`fromPptx` to an OPF document (default `<name>.opf.json` in the working directory; `-` reads or writes stdin/stdout).\nImport is a conversion, not a lossless round trip for arbitrary decks: what it cannot keep is reported as `import/...`\ndiagnostics. `--signals` also writes the raw per-shape layout and style signals (`fromPptx` with `signals: true`, which returns\n`{ presentation, signals }`). The signals are deterministic data; they never leave the machine.\nAI reconstruction of third-party decks is not part of the CLI (it lives in pptx.dev).\n\n## Options shared by `render` and `export`\n\n### Output names\n\nFiles are named by the deck: its root `filename` (a trailing `.pptx`, `.pdf`, `.png` or `.svg` is dropped, in any case), else\nits `name` slugified (`Q4 Review / 2026` becomes `Q4-Review-2026`), else the input file's stem (`deck.opf.json` gives `deck`).\nThe same rule names the editor's downloads. An explicit `--out` is used as given.\n\n### Hidden slides\n\n`render` and `export` skip slides marked `hidden: true` when they write one file per slide, a zip or a PDF, because a hidden slide is\nnot part of the presented sequence. `--include-hidden` writes them too. Slides named with `--slides` are always written, hidden or\nnot. The report lists the slide numbers left out in `skippedHidden`. A deck whose every slide is hidden exits 1 and names the flag.\n\n\n| Option | Meaning |\n| --- | --- |\n| `--include-hidden` | Write hidden slides too (per-slide images, zips and PDF). See [Hidden slides](#hidden-slides). |\n| `--paginate` | Paginate with the same fonts first (as `opf paginate` does, but measured), so overflowing slides split instead of reporting `text-overflow`. `--slides` then counts the paginated slides. |\n| `--date YYYY-MM-DD` | The date for `date: true` header and footer fields. The CLI never reads a clock; without it a current date is reported as unresolved. |\n| `--font-dir <directory>` (repeatable) | Your own `.ttf`/`.otf` files, loaded in addition to the bundled pack, directly inside the directory, sorted by name. A face that repeats a bundled family, weight and style is refused. |\n| `--asset-dir <directory>` | The folder relative image paths resolve against and the only folder read. Default: the document's folder (the working directory for stdin). |\n| `--fail-on <error\\|warning\\|info>` | Findings at or above this severity fail, and nothing is written (default `error`). `--fail-on warning` fails on warnings; every command that checks a document takes it. |\n| `--force` | Replace existing outputs. Without it any existing destination exits 1 before anything is written. |\n| `--json` | Accepted for scripts that pass it everywhere. Reports are always JSON; this is the default. |\n\n## Fonts\n\nThe renderer's bundled open font pack (the office pack: Carlito, Intos for Aptos, the open families font schemes\nselect and the open replacements the font policy routes proprietary families to, with lazy faces) with the visual\nsubstitution policy, plus the files from `--font-dir`. **System fonts are never loaded**, and nothing is downloaded.\nFont substitutions are listed under `fonts.substitutions` in the report with their `compatibility` (`metric` or\n`visual`). The PPTX keeps the font names the document chose.\n\nScripts beyond Latin, Greek and Cyrillic need the optional Noto script packages of the renderer\n(`@expo-google-fonts/noto-sans-jp` and so on). Install the ones named in the `fonts/script-font-not-installed`\ndiagnostic next to the CLI; without them, text the loaded faces cannot draw is the error `render/missing-glyph`.\n\n## Images and assets\n\nRelative image paths and `file:` paths resolve against the document's folder (or `--asset-dir`). Only `.png`, `.jpg`,\n`.jpeg`, `.gif`, `.webp` and `.svg` files whose content matches are read, and only inside that folder (symlinks are\nresolved first), so a document cannot pull another file on the machine into an output. URLs are never fetched. For SVG\nand PNG output, an unreadable image draws the renderer's placeholder with an `unresolved-asset` or `cli/asset-blocked`\nwarning. For PPTX it stops the export (`pptx/asset-unresolved`): opf-pptx would otherwise read the path itself. SVG\npictures in a PPTX get their PNG fallback from the CLI's own opf-render install (`svgRasterizer`), so they export\nwhichever way the packages were installed.\n\n## Markdown and YAML\n\nTwo more groups of commands convert between a deck and text, with no renderer: they are core features (`@openpresentation/opf/markdown` and `@openpresentation/opf/yaml`) and need no optional peer.\n\n```sh\nopf from-md deck.md deck.opf.json # Markdown in the OPF dialect to a validated deck\nopf to-md deck.opf.json deck.md # a deck as Markdown that reads back unchanged\nopf from-yaml deck.opf.yaml deck.opf.json # strict YAML to a validated deck (--aliases expands anchors)\nopf to-yaml deck.opf.json deck.opf.yaml --schema-comment\nopf validate deck.opf.yaml # every command that reads a deck reads .yaml and .yml files\nopf edit deck.opf.json --patch changes.json --output deck.opf.yaml # or --format yaml\nopf validate - --input-format yaml < deck.txt\n```\n\n- `from-md` and `to-md` follow the Markdown dialect (YAML front matter, `---` between slides, `#` title, lists, tables, `chart`, `metric` and `timeline` fences, speaker notes): see [Markdown and outlines](markdown.md). `from-md --format yaml` or a `.yaml` output writes the deck as YAML.\n- `from-yaml` and `to-yaml` read and write a deck as JSON-compatible YAML 1.2, with canonical key order and an optional `# yaml-language-server` line for editor validation: see [OPF as YAML](yaml.md). A file ending `.yaml` or `.yml` is read as YAML by every command; stdin and other names are JSON unless `--input-format yaml` is given. Commands that write a deck write YAML for an output ending `.yaml`/`.yml` or with `--format yaml`. A YAML syntax error exits 2 with the line and column (`opf validate` reports it as a `yaml/<rule>` finding and exits 1); commands that rewrite a YAML file do not preserve its comments and say so on stderr.\n- All of them print JSON reports (on stderr when stdout carries the document) and use the exit codes below: 0 success, 1 invalid content, an output conflict or a finding at or above `--fail-on`, 2 usage, a read error or I/O.\n\n## Diagnostics and exit codes\n\nThe report is the [`opf validate`](validate.md) report with the written files added: `ok`, `valid`, `schemaValid`,\n`findings` (`ruleId`, `severity`, `category`, JSON Pointer `path`, `scope`, `message`, `help`), `counts`, `checks`, `sha256` and\n`opfVersion`. The document's format and references are checked first (not accessibility or layout rules: a contrast\nwarning never blocks a write); an invalid one exits 1 and nothing is rendered. Library findings are added with the\nprefix `render/`, `pptx/`, `pdf/`, `fonts/`, `import/` or `cli/` (the same word is their category), for example\n`render/text-overflow`. Notes (`font-glyph-fallback`, `pdf-font-embedded`, `svg-sanitized`) are `info`; everything else a\nlibrary reports is a `warning`; a failed render, export or import is an `error`. `checks.layout` is `measured`: the\nCLI measures text with the fonts it loaded.\n\n`outputs` lists each file with `sha256`, `bytes`, `mediaType`, and for slides `slide`, `id`, `width`, `height`.\n`renderer` and `pptx` give the package versions used. With `--out -` the file goes to stdout and the report to stderr.\n\nExit `0`: success (warnings allowed). Exit `1`: invalid document, error finding, a finding at or above `--fail-on`, or an\nexisting output. Exit `2`: usage, I/O, a missing or too-old peer, a font directory problem. (`opf validate` has the\nsame codes; text that is not valid JSON is an invalid document, exit `1`, with an `opf/json-syntax` finding.)\n\n## Decisions (RR-27, vetoable)\n\n- **Peers, not bundled.** opf-render and opf-pptx are optional peer dependencies loaded lazily. opf-pptx pulls the\n native `sharp` engine, opf-render `resvg`, `fontkit` and the font packs (about 135 MB installed); bundling them would\n turn a 1.6 MB, dependency-free CLI into one that cannot be installed offline or on a locked-down agent host, and\n validating or editing a document would pay for it. The tarball stays small and `dependencies` stays empty.\n- **Reports are always JSON; `--json` is a no-op alias.** The CLI contract is JSON reports and JSON errors; a second\n text reporter would split every consumer.\n- **No clock.** `--date` is explicit so a rerun tomorrow gives the same bytes.\n- **Fonts are the bundled pack plus `--font-dir`.** Never system fonts (owner font policy).\n- **Stored zip entries.** Deflate output differs between zlib builds, which would make archive hashes host-dependent.\n\n## Follow-up (openpresentation.org)\n\nThe site's CLI and developer docs should gain a \"Render, export and import\" page from this file (install block,\nthe three commands, the report fields), and its quickstart should stop saying the CLI does not render. No public\nsupport or progress status goes on the site (program invariant); the page documents commands, not coverage.\n"
32
32
  },
33
33
  {
34
34
  "slug": "compatibility-matrix",
35
35
  "file": "docs/compatibility-matrix.md",
36
36
  "title": "Compatibility matrix",
37
- "markdown": "# Compatibility matrix\n\nPublished registry evidence for the coordinated Node 24 toolchain. This matrix\nis the honest supported subset for [the developer quickstart](quickstart.md).\nIt is not universal Office parity and does not describe archived prototypes as\nshipped.\n\nVerify live versions with `npm view <package> version` before treating a\ndated handoff as current. The pin set below matches the published 0.13 set recorded in `release-plan.json` (RR-20, 6 October 2026). Immutable tag commits pin\nthe verification harnesses; see [published evidence](evidence/shipped-train-20260921/README.md).\nThe [September 29 source checkpoint](handoff-runtime-2026-09-29.md) records later\naccepted fixes and release prerequisites. Those source changes have not updated\nthe versions below or established complete native compatibility.\n\n## Runtime\n\n| Requirement | Status |\n| --- | --- |\n| Node.js | `engines.node` is `>=22` on every package below; **Node 24** is the coordinated toolchain these records are verified on (the 0.12.2 set and later are tested on Node 22, 24 and 26) |\n| Package managers | npm for published installs; this repo uses pnpm 10.33.2 for core development |\n| Account / model / hosted API | Not required |\n| Operating systems | macOS, Linux, Windows for Node APIs; browser entrypoints are separate |\n\n## Coordinated published packages\n\n| Package | Version | Depends on |\n| --- | --- | --- |\n| `@openpresentation/opf` | 0.13.0 | \u2014 |\n| `@openpresentation/cli` | 0.11.0 | Bundles core 0.13.0; registry metadata has no runtime `dependencies`; optional peers `@openpresentation/opf-render@^0.13.1` and `@openpresentation/opf-pptx@^0.13.2` |\n| `@openpresentation/opf-render` | 0.13.1 | `@openpresentation/opf@^0.13.0` |\n| `@openpresentation/opf-editor` | 0.12.1 | `@openpresentation/opf@^0.13.0`; optional peer `@openpresentation/opf-render@^0.13.1` |\n| `@openpresentation/opf-pptx` | 0.13.2 | `@openpresentation/opf@^0.13.0`; optional peer `@openpresentation/opf-render@^0.13.1` |\n\nInstall the complete pinned set. A caret range starting at 0.10.1 does not\ninclude 0.11.x; old consumers can install a second core and do not establish\nColorRef preview/export support. The renderer, PPTX and editor floors move with\ncore in lockstep (core 0.13.0 with renderer 0.13.1, PPTX 0.13.2 and editor 0.12.1), so\npreview and export resolve one composition.\n\nShared header/footer geometry (`furniture-flow-v2`) is published. PPTX exports\neditable slide shapes tagged `OPF_FURNITURE_V1` with provenance for controlled\nreimport. Published PPTX through 0.11.8 draws every part that way, not as native\nOffice Header/Footer objects. PPTX 0.11.9 and later (RR-11) write the footer's\nfirst text, date and slide number as native `ftr`, `dt` and `sldNum` placeholders\n(with master/layout placeholders, `p:hf` flags and a notes-master flag) at the same\ngeometry and reads them back with or without provenance; the rest stays tagged\nshapes. Native PowerPoint acceptance remains [issue 87](https://github.com/OpenPresentation/opf/issues/87).\n\nThe [Windows native-picture checkpoint](evidence/windows-native-picture-20260921/README.md)\nand accepted [native B/C bundle](evidence/windows-native-edits-20260921/README.md)\nrecord finite picture/furniture edits, current-content provenance reimport and\nsafe fallback, production notes packaging and two controlled reordered-file\nrefusals. Core105 publishes that evidence; PPTX47 adds the tested harness, with\nno new package version. UI image replacement changes geometry, longer header\ntext clips, and duplicated tagged headers overlap. Refused workers retain their\nfailed cleanup state separately from later empty-workspace observations.\nThis is not general native layout/reflow fidelity.\n\nAccepted core106's [tab and font checkpoint](evidence/windows-native-tabs-fonts-20260921/README.md)\nrecords plain native tab target error **0.022655487060546875pt** and tab/literal\ndifference **0.022678375244140625pt**, both above the unchanged **0.02pt** gate.\nIts bounded four-face Carlito edit/save/reopen control passes exact text/style\npersistence, zero observed bounds drift, matching rasters and owned font cleanup.\nMixed-size table fidelity, physical glyph-font identity, fallback/synthesis and actual embedding remain\nopen; embedding was disabled for this control. The Windows supervisor retains sole\nOffice control. This documentation task reads evidence and makes no Office calls.\n\nThe later [read-only font inventory](evidence/windows-native-font-inventory-20260921/README.md)\nretains the four Carlito text styles but reports both Carlito and unexpected\nAptos in `Presentation.Fonts`. The native font allowlist fails, and no embedding\nwas attempted. The original parent report incorrectly fails cleanup because of\na Windows PowerShell 5.1 JSON-array parsing defect; raw stages and registration\nrows establish one owned close and four removals in a separately labeled offline\naudit. The raw failure remains intact. Collection names and flags do not identify\nthe physical font used for each glyph.\n\nThe [read-only mixed-table observation](evidence/windows-native-mixed-table-20260921/README.md)\nretains all 245 characters, one literal tab and five authored runs, with outer\ngeometry within 0.02pt and confirmed owned close/font cleanup. Native soft-line\nboundaries are 92/194 versus the estimated preview's 78/172, and native default\ntab spacing is 72pt. These finite content/style results do not pass table\nedit/save/reopen, browser/native raster agreement or physical glyph identity.\nThe accepted [nine-pair offline tab analysis](evidence/windows-native-tab-analysis-20260921/REPORT.md)\nfinds a 0.05pt-compatible pattern in the observed character starts, with finer saved\ntab coordinates. These inputs do not distinguish relative versus absolute placement\nor establish an internal engine cause. The 0.02pt native tab gate remains failed;\nthe separate 0.1px renderer gate is unchanged. Accepted core108 `9b277e1` and\ncore109 `b2711549` publish bounded evidence only. Windows-owned [core110](https://github.com/OpenPresentation/opf/pull/110)\nis merged as `4f7a4bd494f1a873319eff897423d301d1cfc9d6`, from reviewed fc3c36e\nwith four required PR checks passing. [Renderer30](https://github.com/OpenPresentation/opf-render/pull/30) is now merged\nas `c8d7d5ca1f67a7b39f70c7c4bd14577a865b175b`, with exact-head CI 35661051100\npassing. The supervisor reports postmerge 35661504472 also passed. Its companion\nsource preserves rich-tab advances/spans; the [accepted Windows wrap-up](handoff-windows-native-2026-09-21-wrap-up.md)\nrecords bounded source-linked rendering/browser checks and the original missing-test\nCI failure. These checks do not update the frozen registry consumer. Core111\n`3c5048522714365a41d9b5b9ba81620affae718b` publishes the font-inventory evidence\nabove with both PR workflows green; its postmerge workflows were started at the\nfinal notice, not recorded as passed. Package/lock/release/site pins, schema,\ngoldens and tolerances are unchanged. Native allowlist and physical-glyph/embedding\nacceptance remain open; no new package train or broad native pass is inferred.\n\n## Supported in this set\n\n| Capability | How | Notes |\n| --- | --- | --- |\n| JSON authoring | `*.opf.json` plus CLI `opf create` | Local files only |\n| Bundled examples catalog | `@openpresentation/opf/examples` | **127** decks; the quickstart JSON is a docs fixture, not a 127th catalog entry |\n| Validate | `validate` / `opf validate` | One read-only checker: format, references, policy, accessibility, layout and content findings ([guide](validate.md)); no network catalog fetch |\n| Color references | `ColorRef`, `variables`, `resolveColorRef` | Core schema/resolution, renderer preview and PPTX resolved colors are shipped. Native `schemeClr`/theme writing and editor canvas named-color fidelity remain follow-ups. |\n| Offline catalog bundle | `bundle` / `opf bundle` | Inlines resolved catalog records; remote media/data and custom catalog sources remain explicit host concerns. |\n| Offline fonts | `prepareNodeFonts` (`/fonts-node`) | Bundled Roboto pack; hashed files |\n| Composition | `composeSlide` | Includes shared headers/footers |\n| Pagination | `paginate` / `opf paginate` | Returns mappings; preserves source |\n| Edit + undo | `@openpresentation/opf-editor` `createEditorSession` | JSON Patch undo/redo |\n| JSON Patch CLI | `opf edit` | No persistent CLI undo history |\n| SVG preview | `renderSvg` / `renderSvgDeck` | Local; same options as layout |\n| PNG | `svgToPng` | Node raster of SVG |\n| PDF | `svgToPdf` | opf-render 0.12.0 and later (RR-12, opf-render#90): **vector with selectable text by default** (embedded TrueType subsets of the supplied/bundled fonts, ToUnicode, links, metadata, tagged structure); `mode: \"raster\"` keeps the image-per-slide output. Renderers up to 0.11.9: raster-backed, not selectable text. Not a PDF/UA or PDF/A claim; see the renderer's `docs/evidence/rr-12-vector-pdf.md` for reader limits |\n| Editable PPTX export | `toPptx` | OPF \u2192 PPTX serialization. Furniture is tagged slide shapes (`OPF_FURNITURE_V1`), not native `p:hf` / notes-master Header/Footer objects |\n| Agent skills | `opf skills install` | Offline after the CLI is installed |\n| Browser canvas | `@openpresentation/opf-editor/canvas` | Host must supply font bytes |\n\n## Public sites\n\nThe current source, CI and canonical production results are recorded in the\n[current font-readiness checkpoint](evidence/font-readiness-acceptance-20260921/README.md),\n[prior Inspector actions checkpoint](evidence/inspector-current-actions-20260921/README.md),\n[earlier publication checkpoint](evidence/inspector-share-acceptance-20260921/README.md),\n[source-preservation checkpoint](evidence/author-source-acceptance-20260921/README.md),\n[completion checkpoint](evidence/completion-acceptance-20260921/README.md),\n[earlier acceptance ledger](evidence/issue88-final-20260921/README.md) and\n[handoff](handoff-2026-09-21.md). [Issue88](https://github.com/OpenPresentation/opf/issues/88)\nremains open. Package adoption, deployed features and complete workflow\nacceptance are separate claims.\n\n| Surface | Deployed scope and acceptance | Source commit |\n| --- | --- | --- |\n| [openpresentation.org](https://www.openpresentation.org) | Current published guides, agent skills, JSON/preview workflow and downloads. Exact canonical deployment passes 321 checks across 11 pages and 18 raw resources, plus two browser flows for agent installation/navigation and JSON/SVG/PPTX downloads. Reviewed screenshots and output hashes match the accepted build. | `a85bcc77d899ce9ba1df659548be564142c16120` |\n| [pptx.dev](https://www.pptx.dev) `/inspector` and `/author` | App54 merged/live on exact READY production. Premerge Linux/Windows pass 704 units, 13 standalone controls and 39/39 browsers. Full canonical acceptance **fails (34/39 passed)** at five no-POST assertions; the bounded audit does not establish an introduced upload regression. Postmerge Linux 39/39 passes, Windows 38/39 fails initial font readiness. Preset Undo all and broader source writers remain unresolved. | `8f54228a9e38a1dcc0bf8188bcdd519795b3799a` |\n| [pptx.gallery](https://www.pptx.gallery) `/docs`, `/editor` and gallery pages | Published ColorRef/bundle guidance, Playground and Editor actions, and the canonical docs-to-editor flow are verified. | `f17e9ae5869669d5fbac3720f285652d0c37551c` |\n\nThe site uses documentation source `120a770`, whose tree matches accepted core\nPR98 commit `b1ff81db6f8714b0db1a98bde482ed8a64d0ccc9`. Core PR93/97/98/99/100/102/103 passed\npre-merge and post-merge CI. Accepted core102 is\n`578bcc6e0894129b00059258bd4ad1994a414baa`; its reviewed and accepted trees match,\nand all four required pre/post-merge runs passed on their first attempts. The\n[core102 receipts](evidence/inspector-share-acceptance-20260921/README.md#accepted-core102)\npin this documentation checkpoint without changing the site's older accepted\ndocumentation snapshot. The site's complete guides and raw resources match\nthe reviewed source; binary evidence remains linked and downloadable without\nbeing decoded into the AI-facing guide.\nCore104 `3d301f1` preserves exact postmerge OPF success and coordinated cancellation;\nit is not a complete green postmerge gate. Accepted descendant core105\n`84e914710520a7b0e777fce30e5758ee64a64924` preserves all 60 core104 evidence blobs\nand passes both exact-head workflows on their first attempts. [Compact receipts](evidence/inspector-current-actions-20260921/README.md#core-source-and-ci)\nkeep descendant acceptance separate from the canceled predecessor run.\nAccepted core106 `3847f712ccb2379952bcc8ab7c9fdbaedfd0a4ce` also passes both\npre/postmerge workflows on their first attempts. Its [compact receipt](evidence/inspector-current-actions-20260921/core106/acceptance-receipt.json)\nbinds the bounded native evidence above without completing general compatibility.\nAccepted core107 `5bc0d3f89414b382b2ce48452c7e56e5d66aaf74` has reviewed tree\n`ffdc678beadf0808bc717d67e7fc0a9ec4790127`. Both original postmerge push workflows\n**35652360502 / 35652360551** passed on attempt 1 under Node24.20.0.\n[Compact receipts](evidence/font-readiness-acceptance-20260921/README.md#core107-acceptance)\nretain earlier automatic premerge cancellations separately from the later automatic\nsuccessful pair; no rerun or accepted checkpoint relabels them.\n\nApp47 corrects the pre-app47\ncompletion adapter's rejected layout choices while preserving unchanged source\ntokens and undo history. Accepted commit `0f35352a1445f56ad4bb7c9f4c5609e01f2dd9ae`\nhas reviewed tree `203bdab509d05911f04f234d996f9c91f2b5e4f2`, green Linux/Windows\npre/post-merge CI and its exact READY canonical deployment. The historical App47 **23/24** production run passes all five new completion cases but still fails the existing\nLF Author third-popup assertion. This does not establish complete public-surface\nacceptance; the [historical App47 report](evidence/completion-acceptance-20260921/canonical/REPORT.md)\nand [earlier failed app45/app46 results](evidence/issue88-final-20260921/README.md)\nretain their evidence and unresolved causes.\n\nThe [source audit](evidence/completion-acceptance-20260921/source-preservation-audit/REPORT.md)\nidentified Author canvas/Copy/export and Inspector JSON-download normalization.\nMerged App53 at\n`e40c287b64fcbcfb85fb4a8a50641aea8e3e54a8` has the identical reviewed b33dc18\ntree and preserves those bounded raw-source\npaths and corrects order-only reimport history. A public Suggest-action guard\naddresses the observed stale Quick Input context competing with focused-editor\nCtrl+Space. Current local checks pass **627 unit tests and 29/29 browser cases\nin 88.78 seconds**, zero retries. First-attempt Linux/Windows application CI\npassed 627 unit tests and 29 browser cases per platform; artifact CI also passed.\nThe [exact READY canonical run](evidence/author-source-acceptance-20260921/canonical/REPORT.md) passes **29/29**, zero retries, with matching deployment receipts before and after. Postmerge application CI fails Linux **28/29** while Windows passes **29/29**; both pass 627 unit tests and separate artifact CI passes. The [Linux failure](evidence/author-source-acceptance-20260921/app53/postmerge-ci/README.md) stops before security assertions because five default-deck canvases remain after the shared-load toast. No rerun or canonical pass replaces that failed gate. The retained draft preview was READY but not browser-accepted.\n\nThe earlier **24/27** import-undo regression, **26/27** local popup failure and\nold-head Windows **26/27** shared-load failure remain historical evidence.\nThe fresh successful Windows job retained its sanitized timing artifact, but\nonly Author timings survived; the Inspector pagehide snapshot is missing. This\ndoes not explain or fix the old readiness delay. Phase4\ncaptured no post-fix stale-context overlap, so causal stress is inconclusive.\nThe existing suggestion-details pane remains clipped; visibility is not legibility.\nThe [postmerge trace diagnosis](evidence/author-source-acceptance-20260921/inspector-share-diagnosis/REPORT.md)\nproves wrong-document automatic share-hash publication during import.\nApp54 first corrected automatic\npublication at `60c91f6`: authoritative source/format is checked before and after\nencoding, load/navigation guards remain, and obsolete `import=hash:` is removed.\nURL transfer preserves semantics, not raw spelling. Local 659-unit/31-browser\nacceptance does not replace original first-attempt CI **35638158483**, which\npassed Linux **31/31** but failed Windows **30/31** at the unchanged 45-second\nAuthor readiness deadline. Both publication cases passed. The [frozen diagnosis](evidence/inspector-share-acceptance-20260921/app54/windows-timeout/REPORT.md)\nretains bounded slow-delivery observations with unknown cause. Late assertions\nare not an in-budget pass. Browser History tests permit prior accepted content\nuntil first new publication; held-promise controls establish the narrower race guard.\n\nThe product correction was added at `57e5e59fddbc94346f142dc12d86a916228bf2ae`, tree\n`d9c3aab543c6a886a85c6ec07dd55e5ab22590cd`. It guards current snapshots for\nexplicit Copy/JSON/Share/PPTX/PDF/Author/Deckchat actions. Pending public canvas\ndrafts commit before capture, pointer-blur rejection survives session recovery,\nand newer source/load/navigation/unmount/action invalidates late results. Raw\nCopy and accepted same-format JSON bytes are preserved; handoffs remain semantic.\nAlready-started clipboard/download effects cannot be retroactively canceled.\nThe integrated portable standalone startup verifies packaged runtime assets,\nfonts and traced schema inputs without changing dependencies or published pins.\n\nLocal runtime checks pass **692 unit tests**, **7 standalone controls**, focused\n**8/8** and full **39/39** browser cases, zero retries, with visual review. The\nimmutable 93-file app bundle\nretains stale-draft negative evidence and the initial candidate **7/8** result.\nThe latter did not establish accepted pasted source before releasing mocked PDF 401;\nfinal visible-code preconditions change no product bytes or budgets. PDF/Deckchat\nare locally mocked. CRLF ingress normalized 279 to 274 LF bytes; subsequent exact\nactions preserve the accepted buffer, not that ingress boundary.\n\n**The original 57e packaging gate failed.** First-attempt CI **35645493900** failed Linux and\nWindows typecheck on archived `.spec.ts` evidence copies after each platform\npassed 692 units and 7 standalone controls. The exact-head preview failed with\n`module_not_found`; precise Vercel compiler logs were unavailable. [The first-attempt receipt](evidence/inspector-current-actions-20260921/app54/first57-ci/REPORT.md)\nrecords skipped build/browser steps and no uploaded artifacts. The passing runtime build\npredates those copies. A byte-identical `.ts.txt` archive correction produced captured App54 head\n`6dfc2584698c1306505929a2bc3e427996d66563`, tree\n`f3279495f7685945b7541c90d24f7239407639a0`. Its final-tree local typecheck passes\nwith all 305 product/test inputs unchanged. [The corrected receipt](evidence/inspector-current-actions-20260921/app54/corrected6df/commit-receipt.json)\nbinds its 106-file evidence bundle. [Exact-head CI 35646213757](evidence/inspector-current-actions-20260921/app54/corrected6df/ci/REPORT.md)\npasses 692 units, seven standalone controls, typechecks and build on both platforms;\nLinux passes **39/39** browsers. Windows executes **zero browser tests** because\nstandalone startup fails with `EPERM` while statting its packaged React dependency\nlink. No timing or test-result artifacts were uploaded. The exact-head preview\nis READY, which is metadata only; at that 6df checkpoint App54 was unmerged and undeployed to production.\nThe [current ledger](evidence/inspector-current-actions-20260921/README.md)\nretains the separate 60c readiness, 57e packaging and 6df startup failures. Production then remained App53 `e40c287`, with its original\nfailed Linux postmerge gate preserved separately from canonical **29/29**.\nThe startup-link correction at 4338e57\nthen reached all browser cases: Linux **39/39**, Windows **38/39** in first-attempt\nCI **35649707689**, with 692 units and 13 standalone controls passing per platform.\nThe sole Windows failure was initial canvas-title visibility at five seconds,\nbefore any edit/recovery operation; correct incoming source remained at loading\nfonts. Late font acquisition and eleven incomplete responses at teardown do not\nestablish a permanent stall or a dominant cause. The [failed gate](evidence/font-readiness-acceptance-20260921/README.md#preserved-failed-windows-gate)\nis preserved.\n\nApp54 font-preparation revision `a4eb88ab7aa585c9efb91de4c190c1f1c0c7d0eb`, tree\n`031d893855a540fd2a4d2ec2162605817b8dde16`, overlaps font acquisition with converter\nwarmup while readiness still waits for both. The offline converter barrier and all\n**33 faces / 9,317,044 bytes**, manifest, substitution/measurement policy and\n`document.fonts.ready` gate remain unchanged. Fresh local Node24.21.0 checks pass\n**704 units, 13 standalone controls and 39/39 browsers**, zero retries, including\nunchanged offline export/reimport. Immutable app evidence\nretains reviewed images, exact source/output bindings and the original failed run.\n\n**The original a4eb gate failed:** first-attempt application **35654753237** passes\nLinux **39/39** but fails Windows **38/39**; both pass 704 units, 13 standalone\ncontrols, typecheck and build. The Open in Author URL assertion exceeds its existing\nfive-second deadline at `inspector-actions.spec.ts:176`; later source checks are\nnot reached. Original4338 font readiness passes in this run. No navigation cause\nor data-loss finding is established. The [frozen final audit](evidence/font-readiness-acceptance-20260921/app54/final-a4eb-ci/release-audit.json)\nretains exact source/artifact bindings and the original failed trace; that failure remains preserved.\nThe Python artifact workflow is not applicable under its full-PR path filters,\nnot a fresh pass. Exact-head preview is READY metadata only; an\nunauthenticated request redirects to sign-in, with no preview-browser acceptance.\nAt that a4eb capture App54 was unmerged and production remained App53\n`e40c287`. The separate suggestion-details candidate remains unreleased and supplies\nno acceptance here. No local/browser pass broadens native compatibility.\nThe [bounded navigation diagnosis](evidence/font-readiness-acceptance-20260921/README.md#author-navigation-diagnosis-and-prospective-policy)\nrecords Loading Author and a delayed successful script response: 1490 bytes inferred\nfrom ETag, 766 compressed bytes recorded, actual body absent. Later DOM does not\naccept unreached assertions or identify a cause. App54 accepted merge\n`8f54228a9e38a1dcc0bf8188bcdd519795b3799a` retains reviewed 79ba tree\n`3639d3c14daec94d13711fa2b10f2b927df45eca`, preregisters `waitForURL(load)` before\nthe real action, matching `page.goto` within the unchanged 45-second test and default\nfive-second content budgets. It retains all oracles but deliberately removes the\nincidental five-second navigation deadline. Fresh local **39/39**, zero retries,\npasses in 112.780048s with reviewed source/images and prepared-tree build/typecheck.\nThe immutable app bundle\nretains original failures. Units/standalone controls were not repeated locally;\nfresh first-attempt application **35659187971 passes 704 units, 13 standalone\ncontrols and 39/39 browsers on both Linux and Windows**. Exact-head preview was\nREADY/protected, not browser accepted. The identical reviewed tree is merged/live\nat 8f on READY deployment `dpl_H1FtXx1QSuGxn3MwzJwWJpGtRg8b`, but full canonical\nacceptance **fails (34/39 passed)** at five no-POST assertions observing Clerk environment\nPOSTs. The [safe audit](evidence/font-readiness-acceptance-20260921/app54/canonical8f/write-audit/REPORT.md.txt)\nrecords ten such requests, nine with HTTP200/zero-length bodies and one incomplete.\nNo fixture-content needle was detected in captured fields; uncaptured data remains\nunknown. Three final action page-error assertions were not reached; the two share\ncases passed theirs. The prior auth/config comparison is 28/29 identical, with only\npackage scripts changed. Production is kept without a rollback, test change or\nrerun; the strict gate remains failed. Raw authentication-bearing diagnostics\nremain private. Postmerge CI 35660464578\nfinishes failed: Linux 39/39 passes while Windows 38/39 fails, with 704 units/13 controls/typecheck/build\npassing on each. Windows fails initial gallery-rail title visibility after 5,000ms\nwith correct source, clean schema and Loading slide fonts; later editing/export/\nreimport checks were not reached. The [final audit](evidence/font-readiness-acceptance-20260921/app54/merged8f/postmerge-ci/REPORT.md.txt)\npreserves this separate failed gate without cause inference or a retry. No canonical\npass or general native/font acceptance is claimed. That September 21 checkpoint was paused; the user resumed work on September 29. See the [current source checkpoint](handoff-runtime-2026-09-29.md) for ongoing repairs and release holds.\n\nSeparate local negative controls confirmed that preset Undo all discarded New run\nand imported replacement documents. The guarded correction is now preserved in\ndraft app #58: independent\nreview and local Node 24 checks passed (716 units, 13 standalone controls and\n49 browsers without retries). Original Linux/Windows CI could not start because\nof the account payment/spending-limit restriction; no application CI or production\nacceptance is claimed. Unbusy asynchronous account replacements still need a\nsynchronous invalidation guard and held-response control.\nOther local source writers still require their separate preservation checks. These unresolved local findings and raw imported-file/account/agent/metadata boundaries remain outside\nApp53 and App54. See the [App53 ledger](evidence/author-source-acceptance-20260921/README.md)\nand its immutable application evidence links. Issue88 remains OPEN. Native/font\ncompatibility, required repair and release gates remain separate; geometry is\ndeferred as the coordinated set below. The unimplemented worker candidate remains in the [handoff](handoff-2026-09-21.md).\n\nThe five coordinated geometry drafts (core94, renderer27, editor25, PPTX42,\nsite40) remain unmerged. In particular, site40 is not independently shipped.\n\n## Explicitly not shipped\n\n| Topic | Tracker | Do not describe as done |\n| --- | --- | --- |\n| Linux vs Chromium native-width residual at the 0.1px gate | [opf-render#24](https://github.com/OpenPresentation/opf-render/issues/24) | Rounding that fixes Linux but breaks macOS is rejected |\n| General native PowerPoint fidelity and real Office Header/Footer objects (`p:hf`) | [opf#87](https://github.com/OpenPresentation/opf/issues/87) | Finite B/C and bounded Carlito edit controls above are accepted evidence, as is one finite mixed-size table edit/save/reopen ([evidence](evidence/windows-native-mixed-edit-20260922/README.md)). Tab tolerance, general mixed-size table layout and preview/native wrapping, physical glyph identity/fallback/synthesis, embedding and general layout/reflow fidelity remain open; self-import and tagged furniture do not certify arbitrary Office behavior |\n| Public-surface acceptance checklist | [opf#88](https://github.com/OpenPresentation/opf/issues/88) | Shipping features does not establish every acceptance item; use the checklist and deployment receipts |\n| HarfBuzz / prepared-glyph shaping | Archive branches `codex/archive-shaping-20260915` | Prototypes are preserved, not in npm |\n| Selectable vector PDF | [pdf plan](plans/pdf-export.md) | Shipped in opf-render 0.12.0 (opf-render#90; the browser download entry `@openpresentation/opf-render/export-browser` ships there too); PDF/UA, PDF/A and viewer coverage beyond pdf.js, PDFium and poppler remain open |\n| General SVG diagrams / Mermaid | [diagrams plan](plans/diagrams-svg.md) | Embedded SVG \u2260 native editable primitives |\n| Full visual editor / IME / bidi / repair loop | [developer adoption](plans/developer-adoption-20260915.md) | Schema support \u2260 WYSIWYG coverage |\n\nCLI 0.9.2 and earlier do not render or export PPTX; CLI 0.10.0 adds `opf render`, `opf export` and `opf import`\nthrough the optional peers opf-render and opf-pptx ([CLI reference](cli.md)). The Node `svgToPng` / `svgToPdf`\nAPIs stay Node-only; renderer 0.12.0 adds the separate `@openpresentation/opf-render/export-browser` entry for browsers.\n\n## Predecessor notes\n\n| Older set | Relationship |\n| --- | --- |\n| core 0.12.2, CLI 0.10.1, renderer 0.12.2, PPTX 0.12.4, editor 0.11.3 | Previous coordinated set (the first set whose `engines.node` is the open-ended `>=22`). The 0.13 set adds chart and table data (RR-54, [contract](chart-table-data.md)): strict chart numbers (a value is a finite number or a strict decimal string; `\"12%\"`, `\"$5\"` and `\"Q1\"` are a gap with a `chart-value-not-numeric` warning, never a guessed number), number formats (`DataColumn` `{ name, format }`), top-level `datasets` with `{ \"dataset\", \"fields\" }` references and `chart.mapping`, in core 0.13.0, previewed by renderer 0.13.0, exported and re-imported by PPTX 0.13.0 (`OPF_DATASETS_V1` and `OPF_DATA_V1` provenance), edited in the editor 0.12.x data grid and imported by CLI 0.11.0 (`opf import-data --dataset`). PPTX 0.13.0 also vendors pptxgenjs-plus 4.3.4 (was PptxGenJS 4.0.1) and PPTX 0.13.1 bundles for browsers again. Renderer 0.13.1 and PPTX 0.13.2 pin Sharp 0.35.5 (GHSA-wq5f-xc86-pv6w; output unchanged); editor 0.12.1 replaces 0.12.0, which was never published. Renderer, PPTX and editor raise their core floor to `^0.13.0` and the renderer peer to `^0.13.1` together. |\n| core 0.12.1, CLI 0.10.0, renderer 0.12.0, PPTX 0.12.2, editor 0.11.2 | Previous coordinated set. PPTX 0.12.3 corrects the table range of a chart's embedded workbook (apostrophe-quoted sheet references and bubble-series references), which made Keynote drop category charts on import (opf-pptx#162, opf-pptx#163; every other part of the package is byte-identical). Core, renderer and editor are unchanged and keep the core floor `^0.12.0` and the renderer peer `^0.12.0`. |\n| core 0.12.0, CLI 0.10.0, renderer 0.12.0, PPTX 0.12.2, editor 0.11.2 | Previous coordinated set. Core 0.12.1 deprecates the six plural audience ids (`executives`, `investors`, `customers`, `sales-team`, `marketing-team`, `regulators`) in favour of the singular ids: additive catalog data, validation warns and never errors, and no geometry moves, so renderer, PPTX and editor keep the core floor `^0.12.0`. |\n| core 0.12.0, CLI 0.10.0, renderer 0.12.0, PPTX 0.12.1, editor 0.11.1 | Previous coordinated set (PowerPoint lists only the deck's fonts). PPTX 0.12.2 also names the deck's font in the theme's `Viet` (Vietnamese) and `Uigh` (Uyghur) per-language script entries, which PowerPoint applies to `vi-VN` and `ug` runs and which kept Office's Times New Roman and Arial for those two languages (RR-17; every other language is byte-identical). Editor 0.11.2 adds the `slide-sizes` and `purposes` switch dimensions, `SLIDE_SIZE_PRESETS` and the Slide size and Purpose selects in the Design panel (RR-41, additive API). Both keep the core floor `^0.12.0` and the renderer peer `^0.12.0`. |\n| core 0.12.0, CLI 0.10.0, renderer 0.12.0, PPTX 0.12.0, editor 0.11.0 | Previous coordinated set (the 0.12.0 train). PPTX 0.12.1 writes the deck's fonts so that PowerPoint's font list shows only the fonts the deck uses (FF-05: schema-order `presentation.xml`, an own notes theme, no east-asian or complex-script font on runs, and a theme east-asian slot that is never empty); editor 0.11.1 stops the restore prompt showing a literal `null`. Both keep the core floor `^0.12.0` and the renderer peer `^0.12.0`. |\n| core 0.11.4, CLI 0.9.2, renderer 0.11.9, PPTX 0.11.9, editor 0.10.6 | Previous coordinated set (native header/footer placeholders, SVG pictures, `fromPptx` import signals; the published 0.11.4 train measured in the font-fidelity program). Core 0.12.0 moves geometry (composed font sizes on PowerPoint's 0.01 pt grid, hanging wrap whitespace, promoted regions in reading order, right-to-left decks composed mirrored), so renderer 0.12.0, PPTX 0.12.0 and editor 0.11.0 raise their core floor to `^0.12.0` and the renderer peer of PPTX and editor to `^0.12.0` together; the set adds templates and variables, numbered lists, footnotes, citations and captions and chart options (additive schema), vector PDF with selectable text and the browser export entry, the `<opf-deck>` player, the editor's slide management, autosave, data grid, find and replace, image crop, Review panel and fill UI, and the shared JSON Patch module. CLI 0.10.0 bundles core 0.12.0 and adds `opf audit`, `from-md`, `to-md`, `diff`, `merge`, `format`, `render`, `export` and `import`. |\n| core 0.11.4, CLI 0.9.2, renderer 0.11.9, PPTX 0.11.8, editor 0.10.6 | Previous coordinated set (PPTX 0.11.8 re-imports wrapped rich text as one authored payload; CLI 0.9.2 bundles core 0.11.4). PPTX 0.11.9 writes a deck footer's first text, date and slide number as native PowerPoint Header & Footer placeholders (every export also carries the footer placeholders on its master, layout and notes master, so Insert > Header & Footer works), exports an SVG image as a native SVG picture over a PNG fallback (rasterized in Node by the optional opf-render peer or `options.svgRasterizer`) and adds the opt-in `fromPptx(bytes, {signals: true})` import signals; core floor `^0.11.4` unchanged. |\n| core 0.11.4, CLI 0.9.1, renderer 0.11.9, PPTX 0.11.7, editor 0.10.6 | Previous coordinated set (the design fields compose and export natively). PPTX 0.11.8 re-imports rich text that wraps over several native lines as one authored payload (the export records the line count; decks exported by 0.11.7 import as before) and keeps the core floor `^0.11.4`; CLI 0.9.2 bundles core 0.11.4 (CLI 0.9.1 bundled core 0.11.3) and still requires Node 24. |\n| core 0.11.3, CLI 0.9.1, renderer 0.11.8, PPTX 0.11.6, editor 0.10.5 | Previous coordinated set (the native chartex export by default). Core 0.11.4 composes the design fields (logos on covers and section slides, `contentDirection`, `chartPrimary`, picture bullets, header and footer logos, the accent font), aligns a cover's tag and subtitle with its title, and sizes picture bullets and furniture images as PowerPoint does, so renderer, PPTX and editor raise their core floor to `^0.11.4` together; renderer 0.11.9 draws those fields and applies the tag contrast rule (FF-61: the tag draws in the text colour when the scheme primary is under 4.5:1); PPTX 0.11.7 exports them natively, writes every chart's text at the preview's size (FF-62: 12 pt, not 9 pt), writes slide sections as PowerPoint's section list and restores the authored form of a fresh export on import (a root payload returns as `slides.N.text`, `.items`, `.chart` ... rather than one typed block); editor 0.10.6 is a floor bump. CLI 0.9.1 still bundles core 0.11.3. |\n| core 0.11.3, CLI 0.9.1, renderer 0.11.8, PPTX 0.11.5, editor 0.10.5 | Previous coordinated set (the slide tag draws in the scheme primary colour and PPTX writes it as `a:schemeClr accent1`; the playground loads its base faces through `extraLazyFonts`). PPTX 0.11.6 exports the treemap, histogram, pareto, box-and-whisker, waterfall and funnel charts as native chartex parts by default (`toPptx({chartex: 'auto'})`, confirmed in desktop PowerPoint; `world` stays a clustered column with `chart-data-adapted` because PowerPoint's map needs online geodata; pass `chartex: 'fallback'` for the previous output) and gives chartex text the deck's label colour and font. |\n| core 0.11.3, CLI 0.9.1, renderer 0.11.6, PPTX 0.11.4, editor 0.10.4 | Previous coordinated set (the 100-layout catalog and its geometry, category-axis label rotation, quote and slide-image re-import). Renderer 0.11.7 adds the `extraLazyFonts` registry option and `splitStartupFaces` (a browser host can start with Roboto Regular alone and load its other base faces on demand); renderer 0.11.8 draws the slide tag in the scheme primary colour; PPTX 0.11.5 writes the tag run as `a:schemeClr accent1` where the deck theme holds the primary (the colour is unchanged); editor 0.10.5 loads its playground base faces through `extraLazyFonts`. |\n| core 0.11.2, CLI 0.9.0, renderer 0.11.5, PPTX 0.11.3, editor 0.10.3 | Previous coordinated set (native classic and chartex chart previews, opt-in chartex export, face-level lazy fonts and the font gate's render options). Core 0.11.3 adds the pinned pptx.gallery default catalog and the 70 legacy gallery layout ids (layouts 30 to 100; 25 carry a `composition` or `contentBox` contract, which moves geometry, so renderer, PPTX and editor raise their core floor to `^0.11.3` together); renderer 0.11.6 rotates and skips dense category-axis labels; PPTX 0.11.4 re-imports quote and slide-image payloads and writes theme `a:ea`/`a:cs` only where a script font is selected; editor 0.10.4 is a floor bump; CLI 0.9.1 bundles core 0.11.3. |\n| core 0.11.2, CLI 0.9.0, renderer 0.11.1, PPTX 0.11.0, editor 0.10.0 | Previous coordinated set (lockstep floors, Intos and the open families, selected-name export). Renderer 0.11.2 adds script-face loading (`scripts: 'auto'`); PPTX 0.11.1 adds `design.watermark` export; editor 0.10.2 loads the fonts a document needs before every render (FF-41). Renderer 0.11.3 previews every kept classic chart type natively; PPTX 0.11.2 exports the native construct for each kept classic chart type (with `chart-data-adapted` diagnostics where data is adapted) and writes theme colour references for table and text colours. Renderer 0.11.4 previews the seven chartex chart types natively (the world map as a non-geographic tile grid), keeps the Latin Noto Sans replacement for script schemes under `scripts: 'auto'`, shapes Noto Sans Mongolian, and bundles Raleway and Playfair Display (94 lazy faces); PPTX 0.11.3 adds the opt-in `toPptx({chartex: 'native'})` export of the chartex chart types (the default output is unchanged) and always imports chartex charts. |\n| core 0.11.0, CLI 0.9.0, renderer 0.9.0, PPTX 0.9.1, editor 0.8.0 | Previous coordinated Node 24 set (ColorRef, shared furniture). Renderer and PPTX had different core floors from 0.10.x. |\n| core 0.10.0, renderer/PPTX/CLI 0.8.0, editor 0.7.0 | Previous coordinated Node 24 baseline. Lint and furniture landed across 0.10.0/0.8.0 then layout-placeholder fixes in 0.10.1/0.8.1/0.7.1. |\n| Node 20 | Not valid for these packages |\n| Node 22 / 26 | Declared by the current set (`engines.node` `>=22`) and tested, but Node 24 is the toolchain the evidence runs on. Releases up to core 0.12.1, CLI 0.10.0, renderer 0.12.0, PPTX 0.12.3 and editor 0.11.2 declared `24.x`, so npm on Node 22 or 26 silently picked an older release |\n\nDo not install sibling `../opf-render` dist folders when following the\nquickstart. Packed and registry consumers must resolve `@openpresentation/*`\nfrom npm.\n"
37
+ "markdown": "# Compatibility matrix\n\nPublished registry evidence for the coordinated Node 24 toolchain. This matrix\nis the honest supported subset for [the developer quickstart](quickstart.md).\nIt is not universal Office parity and does not describe archived prototypes as\nshipped.\n\nVerify live versions with `npm view <package> version` before treating a\ndated handoff as current. The pin set below matches the published 0.13 set recorded in `release-plan.json` (RR-20, 6 October 2026). Immutable tag commits pin\nthe verification harnesses; see [published evidence](evidence/shipped-train-20260921/README.md).\nThe [September 29 source checkpoint](handoff-runtime-2026-09-29.md) records later\naccepted fixes and release prerequisites. Those source changes have not updated\nthe versions below or established complete native compatibility.\n\n## Runtime\n\n| Requirement | Status |\n| --- | --- |\n| Node.js | `engines.node` is `>=22` on every package below; **Node 24** is the coordinated toolchain these records are verified on (the 0.12.2 set and later are tested on Node 22, 24 and 26) |\n| Package managers | npm for published installs; this repo uses pnpm 10.33.2 for core development |\n| Account / model / hosted API | Not required |\n| Operating systems | macOS, Linux, Windows for Node APIs; browser entrypoints are separate |\n\n## Coordinated published packages\n\n| Package | Version | Depends on |\n| --- | --- | --- |\n| `@openpresentation/opf` | 0.13.0 | \u2014 |\n| `@openpresentation/cli` | 0.11.0 | Bundles core 0.13.0; registry metadata has no runtime `dependencies`; optional peers `@openpresentation/opf-render@^0.13.1` and `@openpresentation/opf-pptx@^0.13.2` |\n| `@openpresentation/opf-render` | 0.13.1 | `@openpresentation/opf@^0.13.0` |\n| `@openpresentation/opf-editor` | 0.12.1 | `@openpresentation/opf@^0.13.0`; optional peer `@openpresentation/opf-render@^0.13.1` |\n| `@openpresentation/opf-pptx` | 0.13.2 | `@openpresentation/opf@^0.13.0`; optional peer `@openpresentation/opf-render@^0.13.1` |\n\nInstall the complete pinned set. A caret range starting at 0.10.1 does not\ninclude 0.11.x; old consumers can install a second core and do not establish\nColorRef preview/export support. The renderer, PPTX and editor floors move with\ncore in lockstep (core 0.13.0 with renderer 0.13.1, PPTX 0.13.2 and editor 0.12.1), so\npreview and export resolve one composition.\n\nShared header/footer geometry (`furniture-flow-v2`) is published. PPTX exports\neditable slide shapes tagged `OPF_FURNITURE_V1` with provenance for controlled\nreimport. Published PPTX through 0.11.8 draws every part that way, not as native\nOffice Header/Footer objects. PPTX 0.11.9 and later (RR-11) write the footer's\nfirst text, date and slide number as native `ftr`, `dt` and `sldNum` placeholders\n(with master/layout placeholders, `p:hf` flags and a notes-master flag) at the same\ngeometry and reads them back with or without provenance; the rest stays tagged\nshapes. Native PowerPoint acceptance remains [issue 87](https://github.com/OpenPresentation/opf/issues/87).\n\nThe [Windows native-picture checkpoint](evidence/windows-native-picture-20260921/README.md)\nand accepted [native B/C bundle](evidence/windows-native-edits-20260921/README.md)\nrecord finite picture/furniture edits, current-content provenance reimport and\nsafe fallback, production notes packaging and two controlled reordered-file\nrefusals. Core105 publishes that evidence; PPTX47 adds the tested harness, with\nno new package version. UI image replacement changes geometry, longer header\ntext clips, and duplicated tagged headers overlap. Refused workers retain their\nfailed cleanup state separately from later empty-workspace observations.\nThis is not general native layout/reflow fidelity.\n\nAccepted core106's [tab and font checkpoint](evidence/windows-native-tabs-fonts-20260921/README.md)\nrecords plain native tab target error **0.022655487060546875pt** and tab/literal\ndifference **0.022678375244140625pt**, both above the unchanged **0.02pt** gate.\nIts bounded four-face Carlito edit/save/reopen control passes exact text/style\npersistence, zero observed bounds drift, matching rasters and owned font cleanup.\nMixed-size table fidelity, physical glyph-font identity, fallback/synthesis and actual embedding remain\nopen; embedding was disabled for this control. The Windows supervisor retains sole\nOffice control. This documentation task reads evidence and makes no Office calls.\n\nThe later [read-only font inventory](evidence/windows-native-font-inventory-20260921/README.md)\nretains the four Carlito text styles but reports both Carlito and unexpected\nAptos in `Presentation.Fonts`. The native font allowlist fails, and no embedding\nwas attempted. The original parent report incorrectly fails cleanup because of\na Windows PowerShell 5.1 JSON-array parsing defect; raw stages and registration\nrows establish one owned close and four removals in a separately labeled offline\naudit. The raw failure remains intact. Collection names and flags do not identify\nthe physical font used for each glyph.\n\nThe [read-only mixed-table observation](evidence/windows-native-mixed-table-20260921/README.md)\nretains all 245 characters, one literal tab and five authored runs, with outer\ngeometry within 0.02pt and confirmed owned close/font cleanup. Native soft-line\nboundaries are 92/194 versus the estimated preview's 78/172, and native default\ntab spacing is 72pt. These finite content/style results do not pass table\nedit/save/reopen, browser/native raster agreement or physical glyph identity.\nThe accepted [nine-pair offline tab analysis](evidence/windows-native-tab-analysis-20260921/REPORT.md)\nfinds a 0.05pt-compatible pattern in the observed character starts, with finer saved\ntab coordinates. These inputs do not distinguish relative versus absolute placement\nor establish an internal engine cause. The 0.02pt native tab gate remains failed;\nthe separate 0.1px renderer gate is unchanged. Accepted core108 `9b277e1` and\ncore109 `b2711549` publish bounded evidence only. Windows-owned [core110](https://github.com/OpenPresentation/opf/pull/110)\nis merged as `4f7a4bd494f1a873319eff897423d301d1cfc9d6`, from reviewed fc3c36e\nwith four required PR checks passing. [Renderer30](https://github.com/OpenPresentation/opf-render/pull/30) is now merged\nas `c8d7d5ca1f67a7b39f70c7c4bd14577a865b175b`, with exact-head CI 35661051100\npassing. The supervisor reports postmerge 35661504472 also passed. Its companion\nsource preserves rich-tab advances/spans; the [accepted Windows wrap-up](handoff-windows-native-2026-09-21-wrap-up.md)\nrecords bounded source-linked rendering/browser checks and the original missing-test\nCI failure. These checks do not update the frozen registry consumer. Core111\n`3c5048522714365a41d9b5b9ba81620affae718b` publishes the font-inventory evidence\nabove with both PR workflows green; its postmerge workflows were started at the\nfinal notice, not recorded as passed. Package/lock/release/site pins, schema,\ngoldens and tolerances are unchanged. Native allowlist and physical-glyph/embedding\nacceptance remain open; no new package train or broad native pass is inferred.\n\n## Supported in this set\n\n| Capability | How | Notes |\n| --- | --- | --- |\n| JSON authoring | `*.opf.json` plus CLI `opf create` | Local files only |\n| Bundled examples catalog | `@openpresentation/opf/examples` | **127** decks; the quickstart JSON is a docs fixture, not a 127th catalog entry |\n| Validate | `validate` / `opf validate` | One read-only checker: format, references, policy, accessibility, layout and content findings ([guide](validate.md)); no network catalog fetch |\n| Color references | `ColorRef`, `variables`, `resolveColorRef` | Core schema/resolution, renderer preview and PPTX resolved colors are shipped. Native `schemeClr`/theme writing and editor canvas named-color fidelity remain follow-ups. |\n| Embed catalog records | `embed` / `opf embed` | Embeds every record a document references, in the group it resolves in, so it renders with no catalog registered; remote media and data remain explicit host concerns. |\n| Offline fonts | `loadFonts` (`/fonts-node`) | Bundled Roboto pack (`pack: 'base'`) or the office pack; hashed files; the handle is passed as `{ fonts }` to every deck-level call |\n| Composition | `composeSlide` | Includes shared headers/footers |\n| Pagination | `paginate` / `opf paginate` | Returns mappings; preserves source |\n| Edit + undo | `@openpresentation/opf-editor` `createEditorSession` | JSON Patch undo/redo |\n| JSON Patch CLI | `opf edit` | No persistent CLI undo history |\n| SVG preview | `renderSvg` (every slide) / `renderSlideSvg` (one slide) | Local; the same `{ fonts }` as layout |\n| PNG | `svgToPng` | Node raster of SVG |\n| PDF | `svgToPdf` | opf-render 0.12.0 and later (RR-12, opf-render#90): **vector with selectable text by default** (embedded TrueType subsets of the supplied/bundled fonts, ToUnicode, links, metadata, tagged structure); `mode: \"raster\"` keeps the image-per-slide output. Renderers up to 0.11.9: raster-backed, not selectable text. Not a PDF/UA or PDF/A claim; see the renderer's `docs/evidence/rr-12-vector-pdf.md` for reader limits |\n| Editable PPTX export | `toPptx` | OPF \u2192 PPTX serialization. Furniture is tagged slide shapes (`OPF_FURNITURE_V1`), not native `p:hf` / notes-master Header/Footer objects |\n| Agent skills | `opf skills install` | Offline after the CLI is installed |\n| Browser canvas | `@openpresentation/opf-editor/canvas` | Host must supply font bytes |\n\n## Public sites\n\nThe current source, CI and canonical production results are recorded in the\n[current font-readiness checkpoint](evidence/font-readiness-acceptance-20260921/README.md),\n[prior Inspector actions checkpoint](evidence/inspector-current-actions-20260921/README.md),\n[earlier publication checkpoint](evidence/inspector-share-acceptance-20260921/README.md),\n[source-preservation checkpoint](evidence/author-source-acceptance-20260921/README.md),\n[completion checkpoint](evidence/completion-acceptance-20260921/README.md),\n[earlier acceptance ledger](evidence/issue88-final-20260921/README.md) and\n[handoff](handoff-2026-09-21.md). [Issue88](https://github.com/OpenPresentation/opf/issues/88)\nremains open. Package adoption, deployed features and complete workflow\nacceptance are separate claims.\n\n| Surface | Deployed scope and acceptance | Source commit |\n| --- | --- | --- |\n| [openpresentation.org](https://www.openpresentation.org) | Current published guides, agent skills, JSON/preview workflow and downloads. Exact canonical deployment passes 321 checks across 11 pages and 18 raw resources, plus two browser flows for agent installation/navigation and JSON/SVG/PPTX downloads. Reviewed screenshots and output hashes match the accepted build. | `a85bcc77d899ce9ba1df659548be564142c16120` |\n| [pptx.dev](https://www.pptx.dev) `/inspector` and `/author` | App54 merged/live on exact READY production. Premerge Linux/Windows pass 704 units, 13 standalone controls and 39/39 browsers. Full canonical acceptance **fails (34/39 passed)** at five no-POST assertions; the bounded audit does not establish an introduced upload regression. Postmerge Linux 39/39 passes, Windows 38/39 fails initial font readiness. Preset Undo all and broader source writers remain unresolved. | `8f54228a9e38a1dcc0bf8188bcdd519795b3799a` |\n| [pptx.gallery](https://www.pptx.gallery) `/docs`, `/editor` and gallery pages | Published ColorRef/bundle guidance, Playground and Editor actions, and the canonical docs-to-editor flow are verified. | `f17e9ae5869669d5fbac3720f285652d0c37551c` |\n\nThe site uses documentation source `120a770`, whose tree matches accepted core\nPR98 commit `b1ff81db6f8714b0db1a98bde482ed8a64d0ccc9`. Core PR93/97/98/99/100/102/103 passed\npre-merge and post-merge CI. Accepted core102 is\n`578bcc6e0894129b00059258bd4ad1994a414baa`; its reviewed and accepted trees match,\nand all four required pre/post-merge runs passed on their first attempts. The\n[core102 receipts](evidence/inspector-share-acceptance-20260921/README.md#accepted-core102)\npin this documentation checkpoint without changing the site's older accepted\ndocumentation snapshot. The site's complete guides and raw resources match\nthe reviewed source; binary evidence remains linked and downloadable without\nbeing decoded into the AI-facing guide.\nCore104 `3d301f1` preserves exact postmerge OPF success and coordinated cancellation;\nit is not a complete green postmerge gate. Accepted descendant core105\n`84e914710520a7b0e777fce30e5758ee64a64924` preserves all 60 core104 evidence blobs\nand passes both exact-head workflows on their first attempts. [Compact receipts](evidence/inspector-current-actions-20260921/README.md#core-source-and-ci)\nkeep descendant acceptance separate from the canceled predecessor run.\nAccepted core106 `3847f712ccb2379952bcc8ab7c9fdbaedfd0a4ce` also passes both\npre/postmerge workflows on their first attempts. Its [compact receipt](evidence/inspector-current-actions-20260921/core106/acceptance-receipt.json)\nbinds the bounded native evidence above without completing general compatibility.\nAccepted core107 `5bc0d3f89414b382b2ce48452c7e56e5d66aaf74` has reviewed tree\n`ffdc678beadf0808bc717d67e7fc0a9ec4790127`. Both original postmerge push workflows\n**35652360502 / 35652360551** passed on attempt 1 under Node24.20.0.\n[Compact receipts](evidence/font-readiness-acceptance-20260921/README.md#core107-acceptance)\nretain earlier automatic premerge cancellations separately from the later automatic\nsuccessful pair; no rerun or accepted checkpoint relabels them.\n\nApp47 corrects the pre-app47\ncompletion adapter's rejected layout choices while preserving unchanged source\ntokens and undo history. Accepted commit `0f35352a1445f56ad4bb7c9f4c5609e01f2dd9ae`\nhas reviewed tree `203bdab509d05911f04f234d996f9c91f2b5e4f2`, green Linux/Windows\npre/post-merge CI and its exact READY canonical deployment. The historical App47 **23/24** production run passes all five new completion cases but still fails the existing\nLF Author third-popup assertion. This does not establish complete public-surface\nacceptance; the [historical App47 report](evidence/completion-acceptance-20260921/canonical/REPORT.md)\nand [earlier failed app45/app46 results](evidence/issue88-final-20260921/README.md)\nretain their evidence and unresolved causes.\n\nThe [source audit](evidence/completion-acceptance-20260921/source-preservation-audit/REPORT.md)\nidentified Author canvas/Copy/export and Inspector JSON-download normalization.\nMerged App53 at\n`e40c287b64fcbcfb85fb4a8a50641aea8e3e54a8` has the identical reviewed b33dc18\ntree and preserves those bounded raw-source\npaths and corrects order-only reimport history. A public Suggest-action guard\naddresses the observed stale Quick Input context competing with focused-editor\nCtrl+Space. Current local checks pass **627 unit tests and 29/29 browser cases\nin 88.78 seconds**, zero retries. First-attempt Linux/Windows application CI\npassed 627 unit tests and 29 browser cases per platform; artifact CI also passed.\nThe [exact READY canonical run](evidence/author-source-acceptance-20260921/canonical/REPORT.md) passes **29/29**, zero retries, with matching deployment receipts before and after. Postmerge application CI fails Linux **28/29** while Windows passes **29/29**; both pass 627 unit tests and separate artifact CI passes. The [Linux failure](evidence/author-source-acceptance-20260921/app53/postmerge-ci/README.md) stops before security assertions because five default-deck canvases remain after the shared-load toast. No rerun or canonical pass replaces that failed gate. The retained draft preview was READY but not browser-accepted.\n\nThe earlier **24/27** import-undo regression, **26/27** local popup failure and\nold-head Windows **26/27** shared-load failure remain historical evidence.\nThe fresh successful Windows job retained its sanitized timing artifact, but\nonly Author timings survived; the Inspector pagehide snapshot is missing. This\ndoes not explain or fix the old readiness delay. Phase4\ncaptured no post-fix stale-context overlap, so causal stress is inconclusive.\nThe existing suggestion-details pane remains clipped; visibility is not legibility.\nThe [postmerge trace diagnosis](evidence/author-source-acceptance-20260921/inspector-share-diagnosis/REPORT.md)\nproves wrong-document automatic share-hash publication during import.\nApp54 first corrected automatic\npublication at `60c91f6`: authoritative source/format is checked before and after\nencoding, load/navigation guards remain, and obsolete `import=hash:` is removed.\nURL transfer preserves semantics, not raw spelling. Local 659-unit/31-browser\nacceptance does not replace original first-attempt CI **35638158483**, which\npassed Linux **31/31** but failed Windows **30/31** at the unchanged 45-second\nAuthor readiness deadline. Both publication cases passed. The [frozen diagnosis](evidence/inspector-share-acceptance-20260921/app54/windows-timeout/REPORT.md)\nretains bounded slow-delivery observations with unknown cause. Late assertions\nare not an in-budget pass. Browser History tests permit prior accepted content\nuntil first new publication; held-promise controls establish the narrower race guard.\n\nThe product correction was added at `57e5e59fddbc94346f142dc12d86a916228bf2ae`, tree\n`d9c3aab543c6a886a85c6ec07dd55e5ab22590cd`. It guards current snapshots for\nexplicit Copy/JSON/Share/PPTX/PDF/Author/Deckchat actions. Pending public canvas\ndrafts commit before capture, pointer-blur rejection survives session recovery,\nand newer source/load/navigation/unmount/action invalidates late results. Raw\nCopy and accepted same-format JSON bytes are preserved; handoffs remain semantic.\nAlready-started clipboard/download effects cannot be retroactively canceled.\nThe integrated portable standalone startup verifies packaged runtime assets,\nfonts and traced schema inputs without changing dependencies or published pins.\n\nLocal runtime checks pass **692 unit tests**, **7 standalone controls**, focused\n**8/8** and full **39/39** browser cases, zero retries, with visual review. The\nimmutable 93-file app bundle\nretains stale-draft negative evidence and the initial candidate **7/8** result.\nThe latter did not establish accepted pasted source before releasing mocked PDF 401;\nfinal visible-code preconditions change no product bytes or budgets. PDF/Deckchat\nare locally mocked. CRLF ingress normalized 279 to 274 LF bytes; subsequent exact\nactions preserve the accepted buffer, not that ingress boundary.\n\n**The original 57e packaging gate failed.** First-attempt CI **35645493900** failed Linux and\nWindows typecheck on archived `.spec.ts` evidence copies after each platform\npassed 692 units and 7 standalone controls. The exact-head preview failed with\n`module_not_found`; precise Vercel compiler logs were unavailable. [The first-attempt receipt](evidence/inspector-current-actions-20260921/app54/first57-ci/REPORT.md)\nrecords skipped build/browser steps and no uploaded artifacts. The passing runtime build\npredates those copies. A byte-identical `.ts.txt` archive correction produced captured App54 head\n`6dfc2584698c1306505929a2bc3e427996d66563`, tree\n`f3279495f7685945b7541c90d24f7239407639a0`. Its final-tree local typecheck passes\nwith all 305 product/test inputs unchanged. [The corrected receipt](evidence/inspector-current-actions-20260921/app54/corrected6df/commit-receipt.json)\nbinds its 106-file evidence bundle. [Exact-head CI 35646213757](evidence/inspector-current-actions-20260921/app54/corrected6df/ci/REPORT.md)\npasses 692 units, seven standalone controls, typechecks and build on both platforms;\nLinux passes **39/39** browsers. Windows executes **zero browser tests** because\nstandalone startup fails with `EPERM` while statting its packaged React dependency\nlink. No timing or test-result artifacts were uploaded. The exact-head preview\nis READY, which is metadata only; at that 6df checkpoint App54 was unmerged and undeployed to production.\nThe [current ledger](evidence/inspector-current-actions-20260921/README.md)\nretains the separate 60c readiness, 57e packaging and 6df startup failures. Production then remained App53 `e40c287`, with its original\nfailed Linux postmerge gate preserved separately from canonical **29/29**.\nThe startup-link correction at 4338e57\nthen reached all browser cases: Linux **39/39**, Windows **38/39** in first-attempt\nCI **35649707689**, with 692 units and 13 standalone controls passing per platform.\nThe sole Windows failure was initial canvas-title visibility at five seconds,\nbefore any edit/recovery operation; correct incoming source remained at loading\nfonts. Late font acquisition and eleven incomplete responses at teardown do not\nestablish a permanent stall or a dominant cause. The [failed gate](evidence/font-readiness-acceptance-20260921/README.md#preserved-failed-windows-gate)\nis preserved.\n\nApp54 font-preparation revision `a4eb88ab7aa585c9efb91de4c190c1f1c0c7d0eb`, tree\n`031d893855a540fd2a4d2ec2162605817b8dde16`, overlaps font acquisition with converter\nwarmup while readiness still waits for both. The offline converter barrier and all\n**33 faces / 9,317,044 bytes**, manifest, substitution/measurement policy and\n`document.fonts.ready` gate remain unchanged. Fresh local Node24.21.0 checks pass\n**704 units, 13 standalone controls and 39/39 browsers**, zero retries, including\nunchanged offline export/reimport. Immutable app evidence\nretains reviewed images, exact source/output bindings and the original failed run.\n\n**The original a4eb gate failed:** first-attempt application **35654753237** passes\nLinux **39/39** but fails Windows **38/39**; both pass 704 units, 13 standalone\ncontrols, typecheck and build. The Open in Author URL assertion exceeds its existing\nfive-second deadline at `inspector-actions.spec.ts:176`; later source checks are\nnot reached. Original4338 font readiness passes in this run. No navigation cause\nor data-loss finding is established. The [frozen final audit](evidence/font-readiness-acceptance-20260921/app54/final-a4eb-ci/release-audit.json)\nretains exact source/artifact bindings and the original failed trace; that failure remains preserved.\nThe Python artifact workflow is not applicable under its full-PR path filters,\nnot a fresh pass. Exact-head preview is READY metadata only; an\nunauthenticated request redirects to sign-in, with no preview-browser acceptance.\nAt that a4eb capture App54 was unmerged and production remained App53\n`e40c287`. The separate suggestion-details candidate remains unreleased and supplies\nno acceptance here. No local/browser pass broadens native compatibility.\nThe [bounded navigation diagnosis](evidence/font-readiness-acceptance-20260921/README.md#author-navigation-diagnosis-and-prospective-policy)\nrecords Loading Author and a delayed successful script response: 1490 bytes inferred\nfrom ETag, 766 compressed bytes recorded, actual body absent. Later DOM does not\naccept unreached assertions or identify a cause. App54 accepted merge\n`8f54228a9e38a1dcc0bf8188bcdd519795b3799a` retains reviewed 79ba tree\n`3639d3c14daec94d13711fa2b10f2b927df45eca`, preregisters `waitForURL(load)` before\nthe real action, matching `page.goto` within the unchanged 45-second test and default\nfive-second content budgets. It retains all oracles but deliberately removes the\nincidental five-second navigation deadline. Fresh local **39/39**, zero retries,\npasses in 112.780048s with reviewed source/images and prepared-tree build/typecheck.\nThe immutable app bundle\nretains original failures. Units/standalone controls were not repeated locally;\nfresh first-attempt application **35659187971 passes 704 units, 13 standalone\ncontrols and 39/39 browsers on both Linux and Windows**. Exact-head preview was\nREADY/protected, not browser accepted. The identical reviewed tree is merged/live\nat 8f on READY deployment `dpl_H1FtXx1QSuGxn3MwzJwWJpGtRg8b`, but full canonical\nacceptance **fails (34/39 passed)** at five no-POST assertions observing Clerk environment\nPOSTs. The [safe audit](evidence/font-readiness-acceptance-20260921/app54/canonical8f/write-audit/REPORT.md.txt)\nrecords ten such requests, nine with HTTP200/zero-length bodies and one incomplete.\nNo fixture-content needle was detected in captured fields; uncaptured data remains\nunknown. Three final action page-error assertions were not reached; the two share\ncases passed theirs. The prior auth/config comparison is 28/29 identical, with only\npackage scripts changed. Production is kept without a rollback, test change or\nrerun; the strict gate remains failed. Raw authentication-bearing diagnostics\nremain private. Postmerge CI 35660464578\nfinishes failed: Linux 39/39 passes while Windows 38/39 fails, with 704 units/13 controls/typecheck/build\npassing on each. Windows fails initial gallery-rail title visibility after 5,000ms\nwith correct source, clean schema and Loading slide fonts; later editing/export/\nreimport checks were not reached. The [final audit](evidence/font-readiness-acceptance-20260921/app54/merged8f/postmerge-ci/REPORT.md.txt)\npreserves this separate failed gate without cause inference or a retry. No canonical\npass or general native/font acceptance is claimed. That September 21 checkpoint was paused; the user resumed work on September 29. See the [current source checkpoint](handoff-runtime-2026-09-29.md) for ongoing repairs and release holds.\n\nSeparate local negative controls confirmed that preset Undo all discarded New run\nand imported replacement documents. The guarded correction is now preserved in\ndraft app #58: independent\nreview and local Node 24 checks passed (716 units, 13 standalone controls and\n49 browsers without retries). Original Linux/Windows CI could not start because\nof the account payment/spending-limit restriction; no application CI or production\nacceptance is claimed. Unbusy asynchronous account replacements still need a\nsynchronous invalidation guard and held-response control.\nOther local source writers still require their separate preservation checks. These unresolved local findings and raw imported-file/account/agent/metadata boundaries remain outside\nApp53 and App54. See the [App53 ledger](evidence/author-source-acceptance-20260921/README.md)\nand its immutable application evidence links. Issue88 remains OPEN. Native/font\ncompatibility, required repair and release gates remain separate; geometry is\ndeferred as the coordinated set below. The unimplemented worker candidate remains in the [handoff](handoff-2026-09-21.md).\n\nThe five coordinated geometry drafts (core94, renderer27, editor25, PPTX42,\nsite40) remain unmerged. In particular, site40 is not independently shipped.\n\n## Explicitly not shipped\n\n| Topic | Tracker | Do not describe as done |\n| --- | --- | --- |\n| Linux vs Chromium native-width residual at the 0.1px gate | [opf-render#24](https://github.com/OpenPresentation/opf-render/issues/24) | Rounding that fixes Linux but breaks macOS is rejected |\n| General native PowerPoint fidelity and real Office Header/Footer objects (`p:hf`) | [opf#87](https://github.com/OpenPresentation/opf/issues/87) | Finite B/C and bounded Carlito edit controls above are accepted evidence, as is one finite mixed-size table edit/save/reopen ([evidence](evidence/windows-native-mixed-edit-20260922/README.md)). Tab tolerance, general mixed-size table layout and preview/native wrapping, physical glyph identity/fallback/synthesis, embedding and general layout/reflow fidelity remain open; self-import and tagged furniture do not certify arbitrary Office behavior |\n| Public-surface acceptance checklist | [opf#88](https://github.com/OpenPresentation/opf/issues/88) | Shipping features does not establish every acceptance item; use the checklist and deployment receipts |\n| HarfBuzz / prepared-glyph shaping | Archive branches `codex/archive-shaping-20260915` | Prototypes are preserved, not in npm |\n| Selectable vector PDF | [pdf plan](plans/pdf-export.md) | Shipped in opf-render 0.12.0 (opf-render#90; the browser download entry `@openpresentation/opf-render/export-browser` ships there too); PDF/UA, PDF/A and viewer coverage beyond pdf.js, PDFium and poppler remain open |\n| General SVG diagrams / Mermaid | [diagrams plan](plans/diagrams-svg.md) | Embedded SVG \u2260 native editable primitives |\n| Full visual editor / IME / bidi / repair loop | [developer adoption](plans/developer-adoption-20260915.md) | Schema support \u2260 WYSIWYG coverage |\n\nCLI 0.9.2 and earlier do not render or export PPTX; CLI 0.10.0 adds `opf render`, `opf export` and `opf import`\nthrough the optional peers opf-render and opf-pptx ([CLI reference](cli.md)). The Node `svgToPng` / `svgToPdf`\nAPIs stay Node-only; renderer 0.12.0 adds the separate `@openpresentation/opf-render/export-browser` entry for browsers.\n\n## Predecessor notes\n\n| Older set | Relationship |\n| --- | --- |\n| core 0.12.2, CLI 0.10.1, renderer 0.12.2, PPTX 0.12.4, editor 0.11.3 | Previous coordinated set (the first set whose `engines.node` is the open-ended `>=22`). The 0.13 set adds chart and table data (RR-54, [contract](chart-table-data.md)): strict chart numbers (a value is a finite number or a strict decimal string; `\"12%\"`, `\"$5\"` and `\"Q1\"` are a gap with a `chart-value-not-numeric` warning, never a guessed number), number formats (`DataColumn` `{ name, format }`), top-level `datasets` with `{ \"dataset\", \"fields\" }` references and `chart.mapping`, in core 0.13.0, previewed by renderer 0.13.0, exported and re-imported by PPTX 0.13.0 (`OPF_DATASETS_V1` and `OPF_DATA_V1` provenance), edited in the editor 0.12.x data grid and imported by CLI 0.11.0 (`opf import-data --dataset`). PPTX 0.13.0 also vendors pptxgenjs-plus 4.3.4 (was PptxGenJS 4.0.1) and PPTX 0.13.1 bundles for browsers again. Renderer 0.13.1 and PPTX 0.13.2 pin Sharp 0.35.5 (GHSA-wq5f-xc86-pv6w; output unchanged); editor 0.12.1 replaces 0.12.0, which was never published. Renderer, PPTX and editor raise their core floor to `^0.13.0` and the renderer peer to `^0.13.1` together. |\n| core 0.12.1, CLI 0.10.0, renderer 0.12.0, PPTX 0.12.2, editor 0.11.2 | Previous coordinated set. PPTX 0.12.3 corrects the table range of a chart's embedded workbook (apostrophe-quoted sheet references and bubble-series references), which made Keynote drop category charts on import (opf-pptx#162, opf-pptx#163; every other part of the package is byte-identical). Core, renderer and editor are unchanged and keep the core floor `^0.12.0` and the renderer peer `^0.12.0`. |\n| core 0.12.0, CLI 0.10.0, renderer 0.12.0, PPTX 0.12.2, editor 0.11.2 | Previous coordinated set. Core 0.12.1 deprecates the six plural audience ids (`executives`, `investors`, `customers`, `sales-team`, `marketing-team`, `regulators`) in favour of the singular ids: additive catalog data, validation warns and never errors, and no geometry moves, so renderer, PPTX and editor keep the core floor `^0.12.0`. |\n| core 0.12.0, CLI 0.10.0, renderer 0.12.0, PPTX 0.12.1, editor 0.11.1 | Previous coordinated set (PowerPoint lists only the deck's fonts). PPTX 0.12.2 also names the deck's font in the theme's `Viet` (Vietnamese) and `Uigh` (Uyghur) per-language script entries, which PowerPoint applies to `vi-VN` and `ug` runs and which kept Office's Times New Roman and Arial for those two languages (RR-17; every other language is byte-identical). Editor 0.11.2 adds the `slide-sizes` and `purposes` switch dimensions, `SLIDE_SIZE_PRESETS` and the Slide size and Purpose selects in the Design panel (RR-41, additive API). Both keep the core floor `^0.12.0` and the renderer peer `^0.12.0`. |\n| core 0.12.0, CLI 0.10.0, renderer 0.12.0, PPTX 0.12.0, editor 0.11.0 | Previous coordinated set (the 0.12.0 train). PPTX 0.12.1 writes the deck's fonts so that PowerPoint's font list shows only the fonts the deck uses (FF-05: schema-order `presentation.xml`, an own notes theme, no east-asian or complex-script font on runs, and a theme east-asian slot that is never empty); editor 0.11.1 stops the restore prompt showing a literal `null`. Both keep the core floor `^0.12.0` and the renderer peer `^0.12.0`. |\n| core 0.11.4, CLI 0.9.2, renderer 0.11.9, PPTX 0.11.9, editor 0.10.6 | Previous coordinated set (native header/footer placeholders, SVG pictures, `fromPptx` import signals; the published 0.11.4 train measured in the font-fidelity program). Core 0.12.0 moves geometry (composed font sizes on PowerPoint's 0.01 pt grid, hanging wrap whitespace, promoted regions in reading order, right-to-left decks composed mirrored), so renderer 0.12.0, PPTX 0.12.0 and editor 0.11.0 raise their core floor to `^0.12.0` and the renderer peer of PPTX and editor to `^0.12.0` together; the set adds templates and variables, numbered lists, footnotes, citations and captions and chart options (additive schema), vector PDF with selectable text and the browser export entry, the `<opf-deck>` player, the editor's slide management, autosave, data grid, find and replace, image crop, Review panel and fill UI, and the shared JSON Patch module. CLI 0.10.0 bundles core 0.12.0 and adds `opf audit`, `from-md`, `to-md`, `diff`, `merge`, `format`, `render`, `export` and `import`. |\n| core 0.11.4, CLI 0.9.2, renderer 0.11.9, PPTX 0.11.8, editor 0.10.6 | Previous coordinated set (PPTX 0.11.8 re-imports wrapped rich text as one authored payload; CLI 0.9.2 bundles core 0.11.4). PPTX 0.11.9 writes a deck footer's first text, date and slide number as native PowerPoint Header & Footer placeholders (every export also carries the footer placeholders on its master, layout and notes master, so Insert > Header & Footer works), exports an SVG image as a native SVG picture over a PNG fallback (rasterized in Node by the optional opf-render peer or `options.svgRasterizer`) and adds the opt-in `fromPptx(bytes, {signals: true})` import signals; core floor `^0.11.4` unchanged. |\n| core 0.11.4, CLI 0.9.1, renderer 0.11.9, PPTX 0.11.7, editor 0.10.6 | Previous coordinated set (the design fields compose and export natively). PPTX 0.11.8 re-imports rich text that wraps over several native lines as one authored payload (the export records the line count; decks exported by 0.11.7 import as before) and keeps the core floor `^0.11.4`; CLI 0.9.2 bundles core 0.11.4 (CLI 0.9.1 bundled core 0.11.3) and still requires Node 24. |\n| core 0.11.3, CLI 0.9.1, renderer 0.11.8, PPTX 0.11.6, editor 0.10.5 | Previous coordinated set (the native chartex export by default). Core 0.11.4 composes the design fields (logos on covers and section slides, `contentDirection`, `chartPrimary`, picture bullets, header and footer logos, the accent font), aligns a cover's tag and subtitle with its title, and sizes picture bullets and furniture images as PowerPoint does, so renderer, PPTX and editor raise their core floor to `^0.11.4` together; renderer 0.11.9 draws those fields and applies the tag contrast rule (FF-61: the tag draws in the text colour when the scheme primary is under 4.5:1); PPTX 0.11.7 exports them natively, writes every chart's text at the preview's size (FF-62: 12 pt, not 9 pt), writes slide sections as PowerPoint's section list and restores the authored form of a fresh export on import (a root payload returns as `slides.N.text`, `.items`, `.chart` ... rather than one typed block); editor 0.10.6 is a floor bump. CLI 0.9.1 still bundles core 0.11.3. |\n| core 0.11.3, CLI 0.9.1, renderer 0.11.8, PPTX 0.11.5, editor 0.10.5 | Previous coordinated set (the slide tag draws in the scheme primary colour and PPTX writes it as `a:schemeClr accent1`; the playground loads its base faces through `extraLazyFonts`). PPTX 0.11.6 exports the treemap, histogram, pareto, box-and-whisker, waterfall and funnel charts as native chartex parts by default (`toPptx({chartex: 'auto'})`, confirmed in desktop PowerPoint; `world` stays a clustered column with `chart-data-adapted` because PowerPoint's map needs online geodata; pass `chartex: 'fallback'` for the previous output) and gives chartex text the deck's label colour and font. |\n| core 0.11.3, CLI 0.9.1, renderer 0.11.6, PPTX 0.11.4, editor 0.10.4 | Previous coordinated set (the 100-layout catalog and its geometry, category-axis label rotation, quote and slide image re-import). Renderer 0.11.7 adds the `extraLazyFonts` registry option and `splitStartupFaces` (a browser host can start with Roboto Regular alone and load its other base faces on demand); renderer 0.11.8 draws the slide tag in the scheme primary colour; PPTX 0.11.5 writes the tag run as `a:schemeClr accent1` where the deck theme holds the primary (the colour is unchanged); editor 0.10.5 loads its playground base faces through `extraLazyFonts`. |\n| core 0.11.2, CLI 0.9.0, renderer 0.11.5, PPTX 0.11.3, editor 0.10.3 | Previous coordinated set (native classic and chartex chart previews, opt-in chartex export, face-level lazy fonts and the font gate's render options). Core 0.11.3 adds the pinned pptx.gallery default catalog and the 70 legacy gallery layout ids (layouts 30 to 100; 25 carry a `composition` or `contentBox` contract, which moves geometry, so renderer, PPTX and editor raise their core floor to `^0.11.3` together); renderer 0.11.6 rotates and skips dense category-axis labels; PPTX 0.11.4 re-imports quote and slide image payloads and writes theme `a:ea`/`a:cs` only where a script font is selected; editor 0.10.4 is a floor bump; CLI 0.9.1 bundles core 0.11.3. |\n| core 0.11.2, CLI 0.9.0, renderer 0.11.1, PPTX 0.11.0, editor 0.10.0 | Previous coordinated set (lockstep floors, Intos and the open families, selected-name export). Renderer 0.11.2 adds script-face loading (`scripts: 'auto'`); PPTX 0.11.1 adds `design.watermark` export; editor 0.10.2 loads the fonts a document needs before every render (FF-41). Renderer 0.11.3 previews every kept classic chart type natively; PPTX 0.11.2 exports the native construct for each kept classic chart type (with `chart-data-adapted` diagnostics where data is adapted) and writes theme colour references for table and text colours. Renderer 0.11.4 previews the seven chartex chart types natively (the world map as a non-geographic tile grid), keeps the Latin Noto Sans replacement for script schemes under `scripts: 'auto'`, shapes Noto Sans Mongolian, and bundles Raleway and Playfair Display (94 lazy faces); PPTX 0.11.3 adds the opt-in `toPptx({chartex: 'native'})` export of the chartex chart types (the default output is unchanged) and always imports chartex charts. |\n| core 0.11.0, CLI 0.9.0, renderer 0.9.0, PPTX 0.9.1, editor 0.8.0 | Previous coordinated Node 24 set (ColorRef, shared furniture). Renderer and PPTX had different core floors from 0.10.x. |\n| core 0.10.0, renderer/PPTX/CLI 0.8.0, editor 0.7.0 | Previous coordinated Node 24 baseline. Lint and furniture landed across 0.10.0/0.8.0 then layout-placeholder fixes in 0.10.1/0.8.1/0.7.1. |\n| Node 20 | Not valid for these packages |\n| Node 22 / 26 | Declared by the current set (`engines.node` `>=22`) and tested, but Node 24 is the toolchain the evidence runs on. Releases up to core 0.12.1, CLI 0.10.0, renderer 0.12.0, PPTX 0.12.3 and editor 0.11.2 declared `24.x`, so npm on Node 22 or 26 silently picked an older release |\n\nDo not install sibling `../opf-render` dist folders when following the\nquickstart. Packed and registry consumers must resolve `@openpresentation/*`\nfrom npm.\n"
38
38
  },
39
39
  {
40
40
  "slug": "content-item-design-overrides",
@@ -46,43 +46,43 @@ var docsData = Object.freeze([
46
46
  "slug": "content-payloads",
47
47
  "file": "docs/content-payloads.md",
48
48
  "title": "Content Payloads",
49
- "markdown": '# Content Payloads\n\nSlide content lives directly on a slide as a full-slide payload, in layout-agnostic `blocks`, or inside a promoted region key such as `left`, `center+right`, or `top:left`.\n\nThe slide-level `title`, `subtitle` and `tag` are not payloads, but like `text` and `quote.text` they accept a string or `TextRun[]` (colored words, bold, links, `cite`/`footnote` markers); see [Rich text](rich-text.md#rich-headings-and-quotes).\n\nThe optional payload `type` can make intent explicit, but OPF should usually infer the content kind from the field present:\n\n| Field | Inferred type | Notes |\n| --- | --- | --- |\n| `text` | `text` | Plain string or `TextRun[]`. |\n| `bullets` | `text` | Simple text bullets, usually `string[]`. |\n| `items` | `list` | Generic list payload, usually `string[]` or `ListItem[]`. |\n| `image` | `image` | Asset string shorthand or `Asset` object with `src` and optional metadata. |\n| `video` | `video` | Asset string shorthand or `Asset` object with `src` and optional metadata. |\n| `chart` | `chart` | Chart object with `type` and tabular `data`. |\n| `table` | `table` | Table object with optional `columns` and required `rows`. |\n| `code` | `code` | String shorthand or `Code` object with `source`, `language`, `filename`, and `highlight`. |\n| `metric` | `metric` | String/number shorthand or `Metric` object with `value`, `label`, `description`, `unit`, `delta`, `trend`, and `sentiment`. |\n| `quote` | `quote` | String shorthand or `Quote` object with `text` (string or `TextRun[]`), `attribution`, `role`, `photo`, and `source`. |\n| `timeline` | `timeline` | Array shorthand or `Timeline` object with `name`, `description`, and `events`. |\n\n## Items versus bullets\n\nBoth fields draw a bulleted (or numbered) list and share the same nesting `level`, rich-text entries and `numbering`. They differ in what they say about the content:\n\n- `bullets` is **prose bullets**: short lines of an argument, written as text. It is inferred as a `text` payload, so layouts and regions that want text take it, and an entry is a string, `TextRun[]` or `{ text, level }`. Use it for talking points where each line stands alone.\n- `items` is a **structured list**: it is inferred as a `list` payload, and an entry may also carry a `description`, so one entry is a heading plus its detail (`{ "text": "Faster onboarding", "description": "First value in under a day." }`). Use it when entries have the same shape (features, steps, options, risks) or when any of them needs supporting detail.\n\nPick `bullets` for plain talking points and `items` as soon as an entry has a description or the slide\'s layout expects a list. Do not put a heading and its detail into one string with a separator; that is what `description` is for. A payload holds one of the two, not both.\n\n## Code highlight\n\n`code.highlight` marks lines of a code block: a line number or an inclusive `[start, end]` range, 1-based, for example `[3, [5, 7]]`. Lines are counted by line break in `code.source`; a line that wraps is still one line. The preview and the PPTX export draw a theme-derived band behind the marked lines and dim the others slightly; every line keeps at least 4.5:1 contrast against what it sits on (`codeHighlightColors`). The PPTX export draws one native rectangle per run of marked lines behind the per-line text boxes, and import restores `highlight` from the code provenance tag. A line past the last line, or a range written end before start, is the validation warning `code-highlight-out-of-range` or `code-highlight-range-reversed`, and engines ignore it. Pagination keeps each page\'s marked lines, renumbered from 1.\n\n## Color references\n\nEvery content color field \u2014 `TextRun.color`, styled table cell `style.fill` and `style.color`, and table cell border `color` \u2014 accepts three forms:\n\n- A literal hex color: `"#0F172A"`, `"#B42318CC"`.\n- A color-scheme slot or role name, resolved through the effective color scheme after design resolution: slots `accent1`\u2013`accent6`, `dark1`, `dark2`, `light1`, `light2`, `hyperlink`, `followedHyperlink`; roles `primary`, `secondary`, `accent`, `background`, `surface`, `surfaceAlt`, `text`, `textSecondary`. `surfaceAlt` is the alternate surface for banded table rows: core derives it from `surface` and `text` (`surfaceAltColor`), so it always differs visibly from the `surface` of the plain rows, on light and dark slides, and body text keeps 4.5:1 on it.\n- A variable reference `var:<id>` into the top-level `variables` map.\n\n```json\n{\n "variables": { "risk": "#B42318" },\n "slides": [\n {\n "title": "What Could Go Wrong",\n "items": [\n ["Two regions at ", { "text": "85% utilization", "color": "var:risk", "bold": true }],\n ["Mitigations ship in ", { "text": "November", "color": "accent2" }]\n ]\n }\n ]\n}\n```\n\nPrefer names and variables over literal hex: re-theming the deck updates every named reference, while a hex value stays frozen at authoring time. An unknown `var:` id is a validation warning, never an error; engines fall back to their default text color. The styled table cell and border color fields enforce the three forms at the schema level; run colors additionally accept any string so imported decks keep validating \u2014 unrecognized values warn, and renderers fall back to the theme color. See [`design-resolution.md`](./design-resolution.md) for the resolution rules.\n\n## Numbered lists\n\n`numbering` on an `items` or `bullets` payload draws numbers instead of bullets: a style name (`arabic`, `roman-upper`, `roman-lower`, `alpha-upper`, `alpha-lower`), a `{ style, start, suffix }` object, or an array with one entry per list level. PowerPoint export writes native auto-numbers and the preview draws the same numbers. See [numbered lists](numbered-lists.md).\n\n```json\n{ "items": ["Define", "Build", "Ship"], "numbering": { "style": "roman-lower", "suffix": "paren" } }\n```\n\n## Blocks\n\nUse slide-level `blocks` when a slide contains multiple content payloads, but exact placement should be inferred by the renderer. Blocks may contain a concrete content payload or a nested group with its own `blocks` and optional `composition`. Groups cannot mix child blocks with leaf payload fields. See [dynamic composition](dynamic-composition.md) for nesting and inheritance rules.\n\n```json\n{\n "title": "Customer Feedback Summary",\n "blocks": [\n {\n "table": {\n "columns": ["Theme", "Mentions"],\n "rows": [\n ["Speed", 42],\n ["Ease of use", 31]\n ]\n }\n },\n {\n "quote": {\n "text": "The new workflow cut review time in half.",\n "attribution": "Operations Lead",\n "source": "Customer interview"\n }\n }\n ]\n}\n```\n\nAt slide root only, multiple content payload kinds are accepted as shorthand for the equivalent blocks form when there is no explicit `type`, no `blocks`, and no promoted region keys:\n\n```json\n{\n "title": "Habitat & Territory",\n "text": "Jaguars are strongly associated with presence of water and dense cover.",\n "items": [\n "Primary habitats include dense rainforests, swamps, and seasonally flooded wetlands.",\n "Solitary animals that establish and defend large territories."\n ]\n}\n```\n\nThe same shorthand works for other content kinds:\n\n```json\n{\n "title": "Evidence Snapshot",\n "chart": {\n "type": "line",\n "data": {\n "columns": ["Quarter", "Sightings"],\n "rows": [\n ["Q1", 12],\n ["Q2", 18]\n ]\n }\n },\n "quote": {\n "text": "Jaguar conservation depends on connected habitat.",\n "attribution": "Field researcher"\n }\n}\n```\n\n## Chart\n\nChart-specific fields are grouped under `chart`. Do not put loose chart data directly on a slide or region.\n\n```json\n{\n "title": "Revenue Trend",\n "chart": {\n "type": "line",\n "data": {\n "columns": ["Quarter", "Revenue", "Costs"],\n "rows": [\n ["Q1", 12, 8],\n ["Q2", 18, 11],\n ["Q3", 24, 15]\n ]\n }\n }\n}\n```\n\nInline chart data is tabular by default. Renderers convert `columns` and `rows` into series, axes, legends, and workbook data internally.\n\nGive every chart an `alt`: one or two sentences that say what the data shows (the point and the key numbers), not "a chart" or "bar chart". The preview names the chart with it (`role="img"`, `aria-label`) and the PowerPoint export writes it as the chart frame\'s alternative text (`descr`), which `fromPptx` reads back. `"alt": ""` marks a chart decorative, as an empty image `alt` does; `validate` (`opf/chart-text-alternative`) still reports it as info so the choice is reviewed. Without `alt`, text beside the chart (a subtitle, text or table) satisfies the rule. See [chart options](chart-options.md#text-alternative).\n\nValue cells are numbers. A string is read only in strict decimal syntax (`"12"`, `" -3.5 "`, `"1e6"`); anything else (`"12%"`, `"$5"`, `"(5)"`, `"1,234"`, `"Q1"`) is a gap in the preview and the export and a `chart-value-not-numeric` warning, never a guessed value. `null` and `""` are gaps without a warning. Core `chartNumber` is the one rule every engine uses (RR-54; before it the PPTX exporter stripped non-numeric characters, so `"12%"` exported as 12).\n\nA column is a string or a `DataColumn` with a number format, which the data labels, the value axis and the exported workbook use:\n\n```json\n{\n "chart": {\n "type": "column",\n "data": {\n "columns": ["Quarter", { "name": "Revenue", "format": "$#,##0.0" }, { "name": "Margin", "format": "0%" }],\n "rows": [["Q1", 12.4, 0.31], ["Q2", 18.1, 0.34]],\n "source": { "src": "./data/revenue.csv", "retrieved": "2026-10-05" }\n },\n "mapping": { "category": "Quarter", "series": ["Revenue"] }\n }\n}\n```\n\nFormats use the `NumberVariable.format` syntax (`#,##0`, `0.0%`, `$#,##0.00`, `#,##0 units`; a `%` multiplies by 100). `source` records provenance only; engines never read or refresh it. `mapping` picks the category, the X column of a scatter chart and the plotted series by column name; without it the first column is the category and every other column a series. Shared data lives in the top-level `datasets` map and a chart plots it with `"data": { "dataset": "revenue", "fields": ["Quarter", "Revenue"] }`. The full contract, with validation codes and the engine behaviour, is [Chart and table data](chart-table-data.md).\n\nChart data is inline columns and rows or a dataset. A data source by file or asset (`"data": { "src": "asset:revenue-csv", "columns": [...] }`) is not part of the format: no engine loaded it, and the validator rejects it. Import the data inline (with a `source`) or into a dataset instead.\n\n## Table\n\nTable-specific fields are grouped under `table`. Do not put loose `columns` or `rows` directly on a slide or region.\n\n```json\n{\n "title": "Pipeline",\n "table": {\n "columns": ["Stage", "Count", "Value"],\n "rows": [\n ["Qualified", 42, "$1.2M"],\n ["Proposal", 18, "$840K"]\n ]\n }\n}\n```\n\nA table may instead show a dataset: `"table": { "dataset": "revenue", "fields": ["Quarter", "Revenue"] }` (no `rows` or `columns`); it takes its headers, rows and column formats from the dataset, and per-cell styles need an inline table. A column header may be a `DataColumn` (`{ "name": "Revenue", "format": "$#,##0.0" }`) and a styled header or body cell takes a `format`; a number cell displays formatted (the body cell\'s own format wins over the column\'s), and `layoutTable` measures the formatted text. See [Chart and table data](chart-table-data.md).\n\nTable body cells accept strings, numbers, booleans, or `null`. Since core 0.5.0, a cell or column header also accepts the same `TextRun[]` used by rich text:\n\n```json\n{\n "table": {\n "columns": [["Quarter ", {"text": "growth", "bold": true}], "Value"],\n "rows": [\n [["Up ", {"text": "12%", "color": "#008800"}], 12]\n ]\n }\n}\n```\n\nUse the current coordinated Node 24 train: core 0.13.0, renderer 0.13.1, editor 0.12.1 and PPTX 0.13.2. See the [compatibility matrix](compatibility-matrix.md) for exact pins and evidence. Core measures run styles when checking overflow and keeps each row intact when paginating. The renderer traces rich cells for the editor\'s existing formatting, typing and undo controls; the exporter emits editable native text runs. PPTX 0.4.0 introduced import of supported native character styles, paragraph defaults, theme fonts/colors, external links and significant whitespace as rich runs. Unstyled body cells remain strings, and cached display text cannot recover original scalar types or live fields. Conditional table styles, merged geometry and cell fills/borders/alignment remain limited; native PowerPoint visual parity is not yet verified.\n\nCore 0.6.0 adds `layoutTable` from `@openpresentation/opf/composition`. It measures scalar and rich cells, keeps short rows compact, and gives wrapped or multiline rows the height they need. When space is constrained it reduces spare row height before shrinking text, and reports overflow when the minimum fitting size cannot fit. Pass the same `scale`, font family, measurement provider and effective `minFontSize` to each consumer. The returned row boxes, cell text boxes and fits are shared by the coordinated SVG and PPTX implementations; rich table cells use uniform line advances to match native cell paragraph spacing. Native viewer fidelity remains a separate verification boundary.\n\n## Captions\n\nAn `image`, `chart`, `table` or `video` payload takes a `caption`: a string, `TextRun[]`, or `{ "text", "position": "below" | "above", "align": "left" | "center" | "right" }` (defaults `below`, `left`). It sits beside the payload field on a block or promoted-region payload, or on the slide root when the root holds exactly one of those payloads; anywhere else it is a `caption-unsupported-payload` error.\n\n```json\n{\n "title": "Pipeline",\n "blocks": [\n { "image": "asset:funnel", "caption": "Figure 1. Pipeline by stage, Q3" },\n { "table": { "columns": ["Stage", "Count"], "rows": [["Qualified", 42]] }, "caption": { "text": "Table 1. Counts", "position": "above", "align": "center" } }\n ]\n}\n```\n\nCore composition reserves the caption band inside the block\'s region and shrinks the media by its height (`item.caption` carries the band, `item.box` is the media box); the preview and the PPTX export draw that band in the muted text colour at the caption size (0.6 of the body size, never under the readable floor). Captioned blocks are the only ones whose geometry changes. See [footnotes, citations and captions](footnotes-citations-captions.md).\n\n## Code\n\nCode-specific fields are grouped under `code`. A string value is shorthand for `code.source`; use object form when syntax highlighting or a file label matters. In object form, `source` is required. `language` colours the code in the preview and the PowerPoint export (comments, strings, numbers, keywords, names and types, in colours from the deck theme); an unknown language stays plain and the text is never changed. See [dynamic composition](dynamic-composition.md#preview-polish-shared-by-preview-and-export-rr-07) for the supported languages.\n\n```json\n{\n "title": "Decision Rule",\n "code": {\n "source": "if risk > threshold:\\n escalate(owner)\\nelse:\\n approve(change)",\n "language": "python",\n "filename": "decision.py"\n }\n}\n```\n\n## Metric\n\nMetric-specific fields are grouped under `metric`. A string or number value is shorthand for `metric.value`; numeric values stay numeric and are formatted by renderers at display time. Use object form when labels, descriptions, units, deltas, trends, or sentiment matter. A `trend` (`up`, `down`, `flat`) draws an arrow beside its word, coloured with the delta text, in the preview and the PowerPoint export; the arrow always points the way the trend does, and its colour follows `sentiment` (`positive` green, `negative` red, `neutral` the neutral text colour). When `sentiment` is absent up is positive, down is negative and flat is neutral, so set it when the direction is not the verdict: a falling churn, cost or latency is `"trend": "down", "sentiment": "positive"`. Only the trend arrow and the trend and delta text take the colour, so `sentiment` has no visible effect on a metric without a `trend`; the word stays editable text and the arrow carries "Trend: up" as its alternative text (see [dynamic composition](dynamic-composition.md#preview-polish-shared-by-preview-and-export-rr-07)).\n\nThe `number-1x` through `number-6x` layout IDs declare one title placeholder and one through six `metric` placeholders. The IDs retain their existing names; the content kind and payload key are `metric`, not `number` or `text`. For several metrics, use separate `{ "metric": ... }` entries in `blocks`. Choosing a layout does not reinterpret existing text as numeric data.\n\n```json\n{\n "title": "Operating Metric",\n "metric": {\n "value": "42%",\n "label": "Review cycle reduction",\n "description": "Median reduction across customer review workflows.",\n "delta": "+11 pts",\n "trend": "up"\n }\n}\n```\n\nA falling value that is good news keeps its downward arrow and takes the green:\n\n```json\n{\n "title": "Churn",\n "metric": {\n "value": "3.1%",\n "label": "Monthly churn",\n "delta": "-0.6 pts",\n "trend": "down",\n "sentiment": "positive"\n }\n}\n```\n\n## Quote\n\nQuote-specific fields are grouped under `quote`. A string value is shorthand for `quote.text`; use object form when attribution or citation matters. `text` is a string or `TextRun[]` with inline formatting; `attribution`, `role` and `source` are plain strings.\n\n```json\n{\n "title": "Customer Proof",\n "quote": {\n "text": "The new workflow made exceptions visible before they became escalations.",\n "attribution": "Priya Raman",\n "source": "Customer interview"\n }\n}\n```\n\nA testimonial attributes the quote to a person with a title and a face. `role` is the person\'s title and organization, and `photo` is their headshot, an `Asset` (a source string or an object with `src` and `alt`):\n\n```json\n{\n "title": "Customer Proof",\n "quote": {\n "text": "The new workflow made exceptions visible before they became escalations.",\n "attribution": "Priya Raman",\n "role": "VP Operations, Acme",\n "photo": { "src": "asset:priya-raman", "alt": "Priya Raman" },\n "source": "Customer interview, March 2026"\n }\n}\n```\n\nThe footer under the quote is the attribution, then the role on its own line, then the source after ` - ` on the last line. Without a `role` it is the single line it always was (`attribution - source`). With a `photo`, the headshot is a circle at the start edge of the footer row (the left in a left-to-right deck, the right in a right-to-left one), three times the footer font size across, and the footer lines sit beside it, centered on it. The photo is cropped to fill the circle, and its size follows the footer\'s font size when the readability floor shrinks the footer. Without a `photo` nothing else changes. The preview draws it with the circular clip that `design.slideImage` uses for shape `circle`, and PowerPoint export writes a native picture with the `ellipse` geometry and the alt text as its description; importing that file restores `role` and `photo`. A photo needs alt text (the `opf/missing-alt-text` rule of `validate` checks `quote.photo`); an SVG photo exports as its PNG raster. A photo with no attribution, role or source still draws, alone in the footer row. The Markdown dialect has no native form for `role` and `photo`: a quote that carries them is written as an `opf` block, which round trips exactly, and a plain `> \u2014 Name, Title` line stays an attribution.\n\n## Timeline\n\nTimeline-specific fields are grouped under `timeline`. An array value is shorthand for `timeline.events`; use object form when the timeline needs a name or description. Timeline events use `when`, `what`, `description` and `status`. `status` is `done`, `current` or `planned` and marks progress: `done` is a filled marker with normal text, `current` ("we are here") is a ringed marker with a bold label, and `planned` is a hollow outlined marker with muted text. An event without a status draws as a plain filled marker. Schedule health (at risk, blocked) is not a status; say it in the event text. See [Dynamic composition](dynamic-composition.md#timeline-internals) for the exact drawing.\n\n```json\n{\n "title": "Rollout Plan",\n "timeline": {\n "name": "Regional Rollout",\n "description": "Major milestones for the rollout.",\n "events": [\n {\n "when": "Q1",\n "what": "Pilot",\n "description": "Launch with one operations team."\n },\n {\n "when": "Q2",\n "what": "Rollout",\n "description": "Expand to all regions."\n }\n ]\n }\n}\n```\n\nA roadmap with progress states:\n\n```json\n{\n "title": "Product roadmap",\n "timeline": [\n { "when": "Q1", "what": "Discovery", "status": "done" },\n { "when": "Q2", "what": "Pilot", "status": "current" },\n { "when": "Q3", "what": "Rollout", "status": "planned" }\n ]\n}\n```\n\n## Regions\n\nRegion keys address a 3\xD73 grid of rows (`top`, `middle`, `bottom`) and columns (`left`, `center`, `right`):\n\n```\n left center right\n +--------------------+--------------------+--------------------+\n top | top:left | top:center | top:right |\n +--------------------+--------------------+--------------------+\n middle | middle:left | middle:center | middle:right |\n +--------------------+--------------------+--------------------+\n bottom | bottom:left | bottom:center | bottom:right |\n +--------------------+--------------------+--------------------+\n```\n\n- A bare column key (`left`) spans all three rows; a bare row key (`top`) spans all three columns.\n- `+` spans adjacent rows or columns: `center+right`, `top+middle`.\n- `row:column` combines the two: `top:left`, `middle+bottom:center+right`.\n- Keys on one slide must not overlap, and regions cannot be mixed with root payload fields.\n\nSpans compose into common slide shapes:\n\n```\n "left" + "center+right" "top" + "middle+bottom"\n (sidebar + main) (headline band + body)\n +----------+------------------+ +-------------------------------+\n | | | | top |\n | | | +-------------------------------+\n | left | center+right | | |\n | | | | middle+bottom |\n | | | | |\n +----------+------------------+ +-------------------------------+\n\n "top" + "middle+bottom:left" + "middle+bottom:center+right"\n (headline band, then sidebar + main)\n +---------------------------------------------+\n | top |\n +---------------+-----------------------------+\n | | |\n | middle+bottom | middle+bottom:center+right |\n | :left | |\n | | |\n +---------------+-----------------------------+\n```\n\nThe same payload objects work inside regions \u2014 here, the sidebar-plus-main shape:\n\n```json\n{\n "title": "Operating Snapshot",\n "left": {\n "table": {\n "columns": ["Metric", "Value"],\n "rows": [\n ["Revenue", "$4.2M"],\n ["Gross margin", "68%"]\n ]\n }\n },\n "center+right": {\n "chart": {\n "type": "line",\n "data": {\n "columns": ["Month", "Revenue"],\n "rows": [\n ["Jan", 3.4],\n ["Feb", 3.8],\n ["Mar", 4.2]\n ]\n }\n }\n }\n}\n```\n'
49
+ "markdown": '# Content Payloads\n\nSlide content lives directly on a slide as a full-slide payload, in layout-agnostic `blocks`, or inside a promoted region key such as `left`, `center+right`, or `top:left`.\n\nThe slide-level `title`, `subtitle` and `tag` are not payloads, but like `text` and `quote.text` they accept a string or `TextRun[]` (colored words, bold, links, `cite`/`footnote` markers); see [Rich text](rich-text.md#rich-headings-and-quotes).\n\nThe optional payload `type` can make intent explicit, but OPF should usually infer the content kind from the field present:\n\n| Field | Inferred type | Notes |\n| --- | --- | --- |\n| `text` | `text` | Plain string or `TextRun[]`. |\n| `bullets` | `text` | Simple text bullets, usually `string[]`. |\n| `items` | `list` | Generic list payload, usually `string[]` or `ListItem[]`. |\n| `image` | `image` | Asset string shorthand or `Asset` object with `src` and optional metadata. |\n| `video` | `video` | Asset string shorthand or `Asset` object with `src` and optional metadata. |\n| `chart` | `chart` | Chart object with `type` and tabular `data`. |\n| `table` | `table` | Table object with optional `columns` and required `rows`. |\n| `code` | `code` | String shorthand or `Code` object with `source`, `language`, `filename`, and `highlight`. |\n| `metric` | `metric` | String/number shorthand or `Metric` object with `value`, `label`, `description`, `unit`, `delta`, `trend`, and `sentiment`. |\n| `quote` | `quote` | String shorthand or `Quote` object with `text` (string or `TextRun[]`), `attribution`, `role`, `photo`, and `source`. |\n| `timeline` | `timeline` | Array shorthand or `Timeline` object with `name`, `description`, and `events`. |\n\n## Items versus bullets\n\nBoth fields draw a bulleted (or numbered) list and share the same nesting `level`, rich-text entries and `numbering`. They differ in what they say about the content:\n\n- `bullets` is **prose bullets**: short lines of an argument, written as text. It is inferred as a `text` payload, so layouts and regions that want text take it, and an entry is a string, `TextRun[]` or `{ text, level }`. Use it for talking points where each line stands alone.\n- `items` is a **structured list**: it is inferred as a `list` payload, and an entry may also carry a `description`, so one entry is a heading plus its detail (`{ "text": "Faster onboarding", "description": "First value in under a day." }`). Use it when entries have the same shape (features, steps, options, risks) or when any of them needs supporting detail.\n\nPick `bullets` for plain talking points and `items` as soon as an entry has a description or the slide\'s layout expects a list. Do not put a heading and its detail into one string with a separator; that is what `description` is for. A payload holds one of the two, not both.\n\n## Code highlight\n\n`code.highlight` marks lines of a code block: a line number or an inclusive `[start, end]` range, 1-based, for example `[3, [5, 7]]`. Lines are counted by line break in `code.source`; a line that wraps is still one line. The preview and the PPTX export draw a theme-derived band behind the marked lines and dim the others slightly; every line keeps at least 4.5:1 contrast against what it sits on (`codeHighlightColors`). The PPTX export draws one native rectangle per run of marked lines behind the per-line text boxes, and import restores `highlight` from the code provenance tag. A line past the last line, or a range written end before start, is the validation warning `code-highlight-out-of-range` or `code-highlight-range-reversed`, and engines ignore it. Pagination keeps each page\'s marked lines, renumbered from 1.\n\n## Color references\n\nEvery content color field \u2014 `TextRun.color`, styled table cell `style.fill` and `style.color`, and table cell border `color` \u2014 accepts three forms:\n\n- A literal hex color: `"#0F172A"`, `"#B42318CC"`.\n- A color-scheme slot or role name, resolved through the effective color scheme after design resolution: slots `accent1`\u2013`accent6`, `dark1`, `dark2`, `light1`, `light2`, `hyperlink`, `followedHyperlink`; roles `primary`, `secondary`, `accent`, `background`, `surface`, `surfaceAlt`, `text`, `textSecondary`. `surfaceAlt` is the alternate surface for banded table rows: core derives it from `surface` and `text` (`surfaceAltColor`), so it always differs visibly from the `surface` of the plain rows, on light and dark slides, and body text keeps 4.5:1 on it.\n- A variable reference `var:<id>` into the top-level `variables` map.\n\n```json\n{\n "variables": { "risk": "#B42318" },\n "slides": [\n {\n "title": "What Could Go Wrong",\n "items": [\n ["Two regions at ", { "text": "85% utilization", "color": "var:risk", "bold": true }],\n ["Mitigations ship in ", { "text": "November", "color": "accent2" }]\n ]\n }\n ]\n}\n```\n\nPrefer names and variables over literal hex: re-theming the deck updates every named reference, while a hex value stays frozen at authoring time. An unknown `var:` id is a validation warning, never an error; engines fall back to their default text color. The styled table cell and border color fields enforce the three forms at the schema level; run colors additionally accept any string so imported decks keep validating \u2014 unrecognized values warn, and renderers fall back to the theme color. See [`design-resolution.md`](./design-resolution.md) for the resolution rules.\n\n## Numbered lists\n\n`numbering` on an `items` or `bullets` payload draws numbers instead of bullets: a style name (`arabic`, `roman-upper`, `roman-lower`, `alpha-upper`, `alpha-lower`), a `{ style, start, suffix }` object, or an array with one entry per list level. PowerPoint export writes native auto-numbers and the preview draws the same numbers. See [numbered lists](numbered-lists.md).\n\n```json\n{ "items": ["Define", "Build", "Ship"], "numbering": { "style": "roman-lower", "suffix": "paren" } }\n```\n\n## Blocks\n\nUse slide-level `blocks` when a slide contains multiple content payloads, but exact placement should be inferred by the renderer. Blocks may contain a concrete content payload or a nested group with its own `blocks` and optional `composition`. Groups cannot mix child blocks with leaf payload fields. See [dynamic composition](dynamic-composition.md) for nesting and inheritance rules.\n\n```json\n{\n "title": "Customer Feedback Summary",\n "blocks": [\n {\n "table": {\n "columns": ["Theme", "Mentions"],\n "rows": [\n ["Speed", 42],\n ["Ease of use", 31]\n ]\n }\n },\n {\n "quote": {\n "text": "The new workflow cut review time in half.",\n "attribution": "Operations Lead",\n "source": "Customer interview"\n }\n }\n ]\n}\n```\n\nAt slide root only, multiple content payload kinds are accepted as shorthand for the equivalent blocks form when there is no explicit `type`, no `blocks`, and no promoted region keys:\n\n```json\n{\n "title": "Habitat & Territory",\n "text": "Jaguars are strongly associated with presence of water and dense cover.",\n "items": [\n "Primary habitats include dense rainforests, swamps, and seasonally flooded wetlands.",\n "Solitary animals that establish and defend large territories."\n ]\n}\n```\n\nThe same shorthand works for other content kinds:\n\n```json\n{\n "title": "Evidence Snapshot",\n "chart": {\n "type": "line",\n "data": {\n "columns": ["Quarter", "Sightings"],\n "rows": [\n ["Q1", 12],\n ["Q2", 18]\n ]\n }\n },\n "quote": {\n "text": "Jaguar conservation depends on connected habitat.",\n "attribution": "Field researcher"\n }\n}\n```\n\n## Chart\n\nChart-specific fields are grouped under `chart`. Do not put loose chart data directly on a slide or region.\n\n```json\n{\n "title": "Revenue Trend",\n "chart": {\n "type": "line",\n "data": {\n "columns": ["Quarter", "Revenue", "Costs"],\n "rows": [\n ["Q1", 12, 8],\n ["Q2", 18, 11],\n ["Q3", 24, 15]\n ]\n }\n }\n}\n```\n\nInline chart data is tabular by default. Renderers convert `columns` and `rows` into series, axes, legends, and workbook data internally.\n\nGive every chart an `alt`: one or two sentences that say what the data shows (the point and the key numbers), not "a chart" or "bar chart". The preview names the chart with it (`role="img"`, `aria-label`) and the PowerPoint export writes it as the chart frame\'s alternative text (`descr`), which `fromPptx` reads back. `"alt": ""` marks a chart decorative, as an empty image `alt` does; `validate` (`opf/chart-text-alternative`) still reports it as info so the choice is reviewed. Without `alt`, text beside the chart (a subtitle, text or table) satisfies the rule. See [chart options](chart-options.md#text-alternative).\n\nValue cells are numbers. A string is read only in strict decimal syntax (`"12"`, `" -3.5 "`, `"1e6"`); anything else (`"12%"`, `"$5"`, `"(5)"`, `"1,234"`, `"Q1"`) is a gap in the preview and the export and a `chart-value-not-numeric` warning, never a guessed value. `null` and `""` are gaps without a warning. Core `chartNumber` is the one rule every engine uses (RR-54; before it the PPTX exporter stripped non-numeric characters, so `"12%"` exported as 12).\n\nA column is a string or a `DataColumn` with a number format, which the data labels, the value axis and the exported workbook use:\n\n```json\n{\n "chart": {\n "type": "column",\n "data": {\n "columns": ["Quarter", { "name": "Revenue", "format": "$#,##0.0" }, { "name": "Margin", "format": "0%" }],\n "rows": [["Q1", 12.4, 0.31], ["Q2", 18.1, 0.34]],\n "source": { "src": "./data/revenue.csv", "retrieved": "2026-10-05" }\n },\n "mapping": { "category": "Quarter", "series": ["Revenue"] }\n }\n}\n```\n\nFormats use the `NumberVariable.format` syntax (`#,##0`, `0.0%`, `$#,##0.00`, `#,##0 units`; a `%` multiplies by 100). `source` records provenance only; engines never read or refresh it. `mapping` picks the category, the X column of a scatter chart and the plotted series by column name; without it the first column is the category and every other column a series. Shared data lives in the top-level `datasets` map and a chart plots it with `"data": { "dataset": "revenue", "fields": ["Quarter", "Revenue"] }`. The full contract, with validation codes and the engine behaviour, is [Chart and table data](chart-table-data.md).\n\nChart data is inline columns and rows or a dataset. A data source by file or asset (`"data": { "src": "asset:revenue-csv", "columns": [...] }`) is not part of the format: no engine loaded it, and the validator rejects it. Import the data inline (with a `source`) or into a dataset instead.\n\n## Table\n\nTable-specific fields are grouped under `table`. Do not put loose `columns` or `rows` directly on a slide or region.\n\n```json\n{\n "title": "Pipeline",\n "table": {\n "columns": ["Stage", "Count", "Value"],\n "rows": [\n ["Qualified", 42, "$1.2M"],\n ["Proposal", 18, "$840K"]\n ]\n }\n}\n```\n\nA table may instead show a dataset: `"table": { "dataset": "revenue", "fields": ["Quarter", "Revenue"] }` (no `rows` or `columns`); it takes its headers, rows and column formats from the dataset, and per-cell styles need an inline table. A column header may be a `DataColumn` (`{ "name": "Revenue", "format": "$#,##0.0" }`) and a styled header or body cell takes a `format`; a number cell displays formatted (the body cell\'s own format wins over the column\'s), and `layoutTable` measures the formatted text. See [Chart and table data](chart-table-data.md).\n\nTable body cells accept strings, numbers, booleans, or `null`. Since core 0.5.0, a cell or column header also accepts the same `TextRun[]` used by rich text:\n\n```json\n{\n "table": {\n "columns": [["Quarter ", {"text": "growth", "bold": true}], "Value"],\n "rows": [\n [["Up ", {"text": "12%", "color": "#008800"}], 12]\n ]\n }\n}\n```\n\nUse the current coordinated Node 24 train: core 0.14.0, renderer 0.14.0, editor 0.14.2 and PPTX 0.14.0. See the [compatibility matrix](compatibility-matrix.md) for exact pins and evidence. Core measures run styles when checking overflow and keeps each row intact when paginating. The renderer traces rich cells for the editor\'s existing formatting, typing and undo controls; the exporter emits editable native text runs. PPTX 0.4.0 introduced import of supported native character styles, paragraph defaults, theme fonts/colors, external links and significant whitespace as rich runs. Unstyled body cells remain strings, and cached display text cannot recover original scalar types or live fields. Conditional table styles, merged geometry and cell fills/borders/alignment remain limited; native PowerPoint visual parity is not yet verified.\n\nCore 0.6.0 adds `layoutTable` from `@openpresentation/opf/composition`. It measures scalar and rich cells, keeps short rows compact, and gives wrapped or multiline rows the height they need. When space is constrained it reduces spare row height before shrinking text, and reports overflow when the minimum fitting size cannot fit. Pass the same `scale`, font family, measurement provider and effective `minFontSize` to each consumer. The returned row boxes, cell text boxes and fits are shared by the coordinated SVG and PPTX implementations; rich table cells use uniform line advances to match native cell paragraph spacing. Native viewer fidelity remains a separate verification boundary.\n\n## Captions\n\nAn `image`, `chart`, `table` or `video` payload takes a `caption`: a string, `TextRun[]`, or `{ "text", "position": "below" | "above", "align": "left" | "center" | "right" }` (defaults `below`, `left`). It sits beside the payload field on a block or promoted-region payload, or on the slide root when the root holds exactly one of those payloads; anywhere else it is a `caption-unsupported-payload` error.\n\n```json\n{\n "title": "Pipeline",\n "blocks": [\n { "image": "asset:funnel", "caption": "Figure 1. Pipeline by stage, Q3" },\n { "table": { "columns": ["Stage", "Count"], "rows": [["Qualified", 42]] }, "caption": { "text": "Table 1. Counts", "position": "above", "align": "center" } }\n ]\n}\n```\n\nCore composition reserves the caption band inside the block\'s region and shrinks the media by its height (`item.caption` carries the band, `item.box` is the media box); the preview and the PPTX export draw that band in the muted text colour at the caption size (0.6 of the body size, never under the readable floor). Captioned blocks are the only ones whose geometry changes. See [footnotes, citations and captions](footnotes-citations-captions.md).\n\n## Code\n\nCode-specific fields are grouped under `code`. A string value is shorthand for `code.source`; use object form when syntax highlighting or a file label matters. In object form, `source` is required. `language` colours the code in the preview and the PowerPoint export (comments, strings, numbers, keywords, names and types, in colours from the deck theme); an unknown language stays plain and the text is never changed. See [dynamic composition](dynamic-composition.md#preview-polish-shared-by-preview-and-export-rr-07) for the supported languages.\n\n```json\n{\n "title": "Decision Rule",\n "code": {\n "source": "if risk > threshold:\\n escalate(owner)\\nelse:\\n approve(change)",\n "language": "python",\n "filename": "decision.py"\n }\n}\n```\n\n## Metric\n\nMetric-specific fields are grouped under `metric`. A string or number value is shorthand for `metric.value`; numeric values stay numeric and are formatted by renderers at display time. Use object form when labels, descriptions, units, deltas, trends, or sentiment matter. A `trend` (`up`, `down`, `flat`) draws an arrow beside its word, coloured with the delta text, in the preview and the PowerPoint export; the arrow always points the way the trend does, and its colour follows `sentiment` (`positive` green, `negative` red, `neutral` the neutral text colour). When `sentiment` is absent up is positive, down is negative and flat is neutral, so set it when the direction is not the verdict: a falling churn, cost or latency is `"trend": "down", "sentiment": "positive"`. Only the trend arrow and the trend and delta text take the colour, so `sentiment` has no visible effect on a metric without a `trend`; the word stays editable text and the arrow carries "Trend: up" as its alternative text (see [dynamic composition](dynamic-composition.md#preview-polish-shared-by-preview-and-export-rr-07)).\n\nThe `number-1x` through `number-6x` layout IDs declare one title placeholder and one through six `metric` placeholders. The IDs retain their existing names; the content kind and payload key are `metric`, not `number` or `text`. For several metrics, use separate `{ "metric": ... }` entries in `blocks`. Choosing a layout does not reinterpret existing text as numeric data.\n\n```json\n{\n "title": "Operating Metric",\n "metric": {\n "value": "42%",\n "label": "Review cycle reduction",\n "description": "Median reduction across customer review workflows.",\n "delta": "+11 pts",\n "trend": "up"\n }\n}\n```\n\nA falling value that is good news keeps its downward arrow and takes the green:\n\n```json\n{\n "title": "Churn",\n "metric": {\n "value": "3.1%",\n "label": "Monthly churn",\n "delta": "-0.6 pts",\n "trend": "down",\n "sentiment": "positive"\n }\n}\n```\n\n## Quote\n\nQuote-specific fields are grouped under `quote`. A string value is shorthand for `quote.text`; use object form when attribution or citation matters. `text` is a string or `TextRun[]` with inline formatting; `attribution`, `role` and `source` are plain strings.\n\n```json\n{\n "title": "Customer Proof",\n "quote": {\n "text": "The new workflow made exceptions visible before they became escalations.",\n "attribution": "Priya Raman",\n "source": "Customer interview"\n }\n}\n```\n\nA testimonial attributes the quote to a person with a title and a face. `role` is the person\'s title and organization, and `photo` is their headshot, an `Asset` (a source string or an object with `src` and `alt`):\n\n```json\n{\n "title": "Customer Proof",\n "quote": {\n "text": "The new workflow made exceptions visible before they became escalations.",\n "attribution": "Priya Raman",\n "role": "VP Operations, Acme",\n "photo": { "src": "asset:priya-raman", "alt": "Priya Raman" },\n "source": "Customer interview, March 2026"\n }\n}\n```\n\nThe footer under the quote is the attribution, then the role on its own line, then the source after ` - ` on the last line. Without a `role` it is the single line it always was (`attribution - source`). With a `photo`, the headshot is a circle at the start edge of the footer row (the left in a left-to-right deck, the right in a right-to-left one), three times the footer font size across, and the footer lines sit beside it, centered on it. The photo is cropped to fill the circle, and its size follows the footer\'s font size when the readability floor shrinks the footer. Without a `photo` nothing else changes. The preview draws it with the circular clip that an image block uses for shape `circle`, and PowerPoint export writes a native picture with the `ellipse` geometry and the alt text as its description; importing that file restores `role` and `photo`. A photo needs alt text (the `opf/missing-alt-text` rule of `validate` checks `quote.photo`); an SVG photo exports as its PNG raster. A photo with no attribution, role or source still draws, alone in the footer row. The Markdown dialect has no native form for `role` and `photo`: a quote that carries them is written as an `opf` block, which round trips exactly, and a plain `> \u2014 Name, Title` line stays an attribution.\n\n## Timeline\n\nTimeline-specific fields are grouped under `timeline`. An array value is shorthand for `timeline.events`; use object form when the timeline needs a name or description. Timeline events use `when`, `what`, `description` and `status`. `status` is `done`, `current` or `planned` and marks progress: `done` is a filled marker with normal text, `current` ("we are here") is a ringed marker with a bold label, and `planned` is a hollow outlined marker with muted text. An event without a status draws as a plain filled marker. Schedule health (at risk, blocked) is not a status; say it in the event text. See [Dynamic composition](dynamic-composition.md#timeline-internals) for the exact drawing.\n\n```json\n{\n "title": "Rollout Plan",\n "timeline": {\n "name": "Regional Rollout",\n "description": "Major milestones for the rollout.",\n "events": [\n {\n "when": "Q1",\n "what": "Pilot",\n "description": "Launch with one operations team."\n },\n {\n "when": "Q2",\n "what": "Rollout",\n "description": "Expand to all regions."\n }\n ]\n }\n}\n```\n\nA roadmap with progress states:\n\n```json\n{\n "title": "Product roadmap",\n "timeline": [\n { "when": "Q1", "what": "Discovery", "status": "done" },\n { "when": "Q2", "what": "Pilot", "status": "current" },\n { "when": "Q3", "what": "Rollout", "status": "planned" }\n ]\n}\n```\n\n## Regions\n\nRegion keys address a 3\xD73 grid of rows (`top`, `middle`, `bottom`) and columns (`left`, `center`, `right`):\n\n```\n left center right\n +--------------------+--------------------+--------------------+\n top | top:left | top:center | top:right |\n +--------------------+--------------------+--------------------+\n middle | middle:left | middle:center | middle:right |\n +--------------------+--------------------+--------------------+\n bottom | bottom:left | bottom:center | bottom:right |\n +--------------------+--------------------+--------------------+\n```\n\n- A bare column key (`left`) spans all three rows; a bare row key (`top`) spans all three columns.\n- `+` spans adjacent rows or columns: `center+right`, `top+middle`.\n- `row:column` combines the two: `top:left`, `middle+bottom:center+right`.\n- Keys on one slide must not overlap, and regions cannot be mixed with root payload fields.\n\nSpans compose into common slide shapes:\n\n```\n "left" + "center+right" "top" + "middle+bottom"\n (sidebar + main) (headline band + body)\n +----------+------------------+ +-------------------------------+\n | | | | top |\n | | | +-------------------------------+\n | left | center+right | | |\n | | | | middle+bottom |\n | | | | |\n +----------+------------------+ +-------------------------------+\n\n "top" + "middle+bottom:left" + "middle+bottom:center+right"\n (headline band, then sidebar + main)\n +---------------------------------------------+\n | top |\n +---------------+-----------------------------+\n | | |\n | middle+bottom | middle+bottom:center+right |\n | :left | |\n | | |\n +---------------+-----------------------------+\n```\n\nThe same payload objects work inside regions \u2014 here, the sidebar-plus-main shape:\n\n```json\n{\n "title": "Operating Snapshot",\n "left": {\n "table": {\n "columns": ["Metric", "Value"],\n "rows": [\n ["Revenue", "$4.2M"],\n ["Gross margin", "68%"]\n ]\n }\n },\n "center+right": {\n "chart": {\n "type": "line",\n "data": {\n "columns": ["Month", "Revenue"],\n "rows": [\n ["Jan", 3.4],\n ["Feb", 3.8],\n ["Mar", 4.2]\n ]\n }\n }\n }\n}\n```\n'
50
50
  },
51
51
  {
52
52
  "slug": "conversions",
53
53
  "file": "docs/conversions.md",
54
54
  "title": "Content conversions",
55
- "markdown": '# Content conversions\n\n`@openpresentation/opf/convert` (RR-26) holds the pure, deterministic converters behind the editor\'s "Content type" control, the list level keys, grouping, image-to-design and the slide split and merge. They need no renderer, fonts, DOM, network or model: a function takes OPF JSON and returns new OPF JSON.\n\nEvery conversion follows one contract:\n\n- **Never invents content.** Every word, number and date in the result came from the source. The only generated text is the fixed column headings of a timeline or metric table (`When`, `What`, `Description`, `Label`, `Value`, `Unit`, `Delta`, `Trend`, `Sentiment`), the `---` row of a Markdown table, and the `title=` of a code fence; a heading row can be left off (`headings: false`).\n- **Reports what it cannot carry.** A result has `lossless` and `loss`, a list of names such as `text formatting` or `list nesting levels`. `lossless` is true exactly when `loss` is empty. Show `loss` before applying.\n- **Refuses with a reason.** A pair with no mapping, or content that does not fit, throws `OPFConversionError` (`code: "not-convertible"`) with a message that says what to change. `contentConversionTargets()` returns the same refusal as `available: false` and `reason`.\n- **Validates its output** as OPF. A result that would not validate throws `code: "invalid-output"`.\n- **Is pure.** The input is never changed and the same input gives the same output. Slide-level functions take and return plain slide or presentation objects; the host turns the difference into a patch, so a conversion is one undoable step (the editor does this in `convertBlock`).\n\n```js\nimport { contentConversionTargets, convertContent } from "@openpresentation/opf/convert";\n\nconst block = { items: ["Plan", { text: "Ship", description: "By June" }] };\ncontentConversionTargets(block);\n// [{ kind: "text", lossless: false, loss: ["list item descriptions (kept as indented lines)"], ... },\n// { kind: "timeline", ... }, { kind: "table", lossless: true, loss: [] }]\nconst { payload, lossless, loss } = convertContent(block, "table");\n// payload: { table: { rows: [["Plan", null], ["Ship", "By June"]] } }\n```\n\nA payload is a block, a slide or region that holds one content field, or a group of metric blocks. `id`, `extensions`, `type` (kept in step with the new kind) and any slide fields (`title`, `design`, `notes`, ...) stay on the result.\n\n## Block conversions\n\n| From | To | Lossless? | What is lost or reported |\n| --- | --- | --- | --- |\n| text | list | when every line is plain, not blank and not numbered | `blank lines`; `list numbering` when `1.` markers are stripped. Indentation (2 spaces or a tab) and `-`, `*`, `\u2022`, `+`, `1.` markers become nesting levels; formatting is kept |\n| text | quote | when the text is plain | `text formatting`. A trailing dash line (`\u2014 Name, Title`, `\u2013`, `--`, `~`, `-`) is the attribution and a second one the source; a last line ``\u201CQuote\u201D \u2014 Name`` splits when the quote opens with a quote mark. Never guessed from a lone line, a dashed list or a line over 120 characters |\n| text | metric | when plain | `blank lines`, `text formatting`. Refused when the first line is over 24 characters; first line is the value, the second the label, the rest the description |\n| text | code | when plain | `text formatting`. One fenced block (` ``` ` or `~~~`) gives the source, the language (first word of the info string) and the file name (`title=`, `filename=`, `file=`); nothing is guessed |\n| text | timeline | when plain | `blank lines`, `text formatting`. Lines like `2024 \u2014 Launch`, `Q1 2026: Pilot`, `Jan - Kickoff`, `2026-03` give `when`; an indented line is the previous event\'s description; `looseWhen: true` also splits `Label: text` |\n| text | table | when plain | Markdown pipe tables (the separator row makes the headings), tab-separated lines, or `delimiter`/`header` options. Refused without that structure or with uneven rows |\n| list | text | when plain, no descriptions, levels in order | `list nesting levels (renumbered...)` for gaps in levels; `list item descriptions (kept as indented lines)`. Levels are written as 2-space indentation |\n| list | timeline | no when the list nests or is rich | `list nesting levels`, `text formatting`. `2024 \u2014 Launch` gives `when`; an item\'s description is the event\'s description |\n| list | table | yes without nesting | `list nesting levels`. One column, or text and description when any item has one; no headings are invented |\n| quote | text | yes | text, then `\u2014 attribution, role` (the role joins the attribution after a comma), then `\u2014 source` (the source line is plain when there is no attribution or role); loses the `quote photo` |\n| metric | text | no with a trend or sentiment | `metric trend`, `metric sentiment` |\n| code | text | yes with `fences: "auto"` (default) | A fenced block keeps the language and file name; with `fences: "never"` the loss is `code language`, `code filename` |\n| timeline | text | yes | `timeline name`, `timeline description` for the metadata a text cannot hold; `when: what`, then the description indented |\n| timeline | list | yes | `timeline name`, `timeline description`; `when: what` is the item text, the description its description |\n| timeline | table | yes | `timeline name`, `timeline description`. Columns `When`, `What`, `Description` only for the fields used |\n| chart | table | no | `chart type`. Only inline `columns` and `rows`, or a dataset; a chart without them is refused |\n| table | chart | yes | Refused unless every column has a plain label and every value after the first column is a number, a numeric string or empty; type `column` |\n| table | list | no | `column headings`; `table columns beyond the second (joined into the description)`; `cell styles`. First column is the item, the other columns the description. Merged cells are refused |\n| table | timeline | yes when the headings are recognised | Reads columns by heading (`When`/`Date`/`Quarter`..., `What`/`Event`/`Milestone`..., `Description`/`Notes`...) or by `columns: { when, what, description }`; `column "X"` for dropped columns, `column heading "X"` for a heading it did not recognise, `text formatting`. Refused without headings or an event column |\n| table | text | yes for plain cells | `text formatting`, `cell styles`, `merged cells`, `line breaks inside cells`, `empty rows`. Markdown with headings, tab-separated without |\n| table | metric blocks | yes when the headings are recognised | Needs a `Value` column; `Label`, `Unit`, `Delta`, `Trend`, `Sentiment`, `Description` are read too; `column "X"`, `trend values other than up, down or flat`, `sentiment values other than positive, negative or neutral`, `text formatting` |\n| group of metric blocks | table | yes | `block ids and extensions`, `group arrangement (composition)` (a group\'s composition cannot sit on a table; a slide keeps its own). Columns only for the fields used |\n\nImages, videos and any other group have no content conversion.\n\n## Other helpers\n\n| Function | What it does | Lossless? |\n| --- | --- | --- |\n| `demoteListItems`, `promoteListItems`, `shiftListLevels` | Nest an item one level deeper (Tab) or one level up (Shift+Tab). A level is at most one more than the item above and never below 0; the first item cannot nest. Items under it move with it unless `withChildren: false`. Level 0 collapses back to the plain form | yes; `changed: false` and a `reason` when nothing can move |\n| `convertListForm` | `items` to `bullets` and back | `list item descriptions` when going to bullets |\n| `wrapBlocks` | Put the selected blocks of a slide or group into a new group, optionally with a composition | yes |\n| `unwrapGroup` | Replace a group in a `blocks` list by its blocks | `group arrangement (composition)`, `group id`, `group extensions` |\n| `blocksToRegions`, `regionsToBlocks`, `moveRegion` | Move blocks into named regions (one block each, no overlap), turn regions back into blocks in reading order, move or swap a region | `region placement` going to blocks |\n| `promoteImage` | Move an image block into `design.slideImage` (`position`, default `right`), `design.background` or `design.watermark` (`opacity`, default 0.1); refuses an occupied slot unless `replace: true` | `image alt text` (background, watermark), `image title`/`description`/`mediaType`/`format`, `block id` |\n| `demoteImage` | Move the slide image, background image or watermark back into the content as an image block | `image position and framing`, `background fit and opacity`, `watermark opacity` |\n| `splitSlide` | Split a slide by blocks (`at: [indexes]` or `each: true`); design, layout and the other slide fields are copied, headings repeat (`repeatHeadings`), the first slide keeps the id and notes, the others get free ids ending `--2`, `--3`. Regions split region by region | `region placement` for regions |\n| `splitSlideOnOverflow` | Split a slide that does not fit with the existing pagination (`paginate`, one slide as slide 1 of 1, host `options` for fonts and measurement) and return the `pages` mapping | yes |\n| `mergeSlides` | Merge consecutive slides: the first slide\'s id, headings and design win, blocks follow each other, notes are joined with a blank line | `slide id "x"`, `title of slide N`, any other field that differs; regions are refused |\n| `unpaginate` | The inverse of pagination for slides still as `paginate` returned them, given its `pages`: slices of text, items, rows, events and code are joined into the original leaves | yes (the pagination readability floor stays in `composition.minFontSize`); refused when the slices are not consecutive |\n\nSlide-level functions return the replacement slides, the replaced `range` (`start`, `deleteCount`) and the new `presentation`; structure functions return the new `slide` and the `path` of what they touched.\n\n## Decisions\n\n- Column headings of timeline and metric tables are the schema\'s own field names. They are the only generated text and can be omitted; reading a table back needs headings (or explicit `columns`), because a role is never guessed from position.\n- Text-to-timeline and text-to-quote parse only unambiguous patterns; the rest stays text. Parsing a pattern is not loss, and a wrong guess would be invented structure.\n- Code to text writes a fenced block when the code has a language or file name, so the round trip is lossless. A fence and its `title=` are the visible cost; `fences: "never"` writes the bare source and reports the loss.\n- Un-paginate needs the pagination mapping. Without it, `mergeSlides` concatenates blocks and never glues text together, because pagination\'s exact slice boundaries are not recoverable from the slides.\n'
55
+ "markdown": '# Content conversions\n\n`@openpresentation/opf/convert` (RR-26) holds the pure, deterministic converters behind the editor\'s "Content type" control, the list level keys, grouping, image-to-design and the slide split and merge. They need no renderer, fonts, DOM, network or model: a function takes OPF JSON and returns new OPF JSON.\n\nEvery conversion follows one contract:\n\n- **Never invents content.** Every word, number and date in the result came from the source. The only generated text is the fixed column headings of a timeline or metric table (`When`, `What`, `Description`, `Label`, `Value`, `Unit`, `Delta`, `Trend`, `Sentiment`), the `---` row of a Markdown table, and the `title=` of a code fence; a heading row can be left off (`headings: false`).\n- **Reports what it cannot carry.** A result has `lossless` and `loss`, a list of names such as `text formatting` or `list nesting levels`. `lossless` is true exactly when `loss` is empty. Show `loss` before applying.\n- **Refuses with a reason.** A pair with no mapping, or content that does not fit, throws `OPFConversionError` (`code: "not-convertible"`) with a message that says what to change. `contentConversionTargets()` returns the same refusal as `available: false` and `reason`.\n- **Validates its output** as OPF. A result that would not validate throws `code: "invalid-output"`.\n- **Is pure.** The input is never changed and the same input gives the same output. Slide-level functions take and return plain slide or presentation objects; the host turns the difference into a patch, so a conversion is one undoable step (the editor does this in `convertBlock`).\n\n```js\nimport { contentConversionTargets, convertContent } from "@openpresentation/opf/convert";\n\nconst block = { items: ["Plan", { text: "Ship", description: "By June" }] };\ncontentConversionTargets(block);\n// [{ kind: "text", lossless: false, loss: ["list item descriptions (kept as indented lines)"], ... },\n// { kind: "timeline", ... }, { kind: "table", lossless: true, loss: [] }]\nconst { payload, lossless, loss } = convertContent(block, "table");\n// payload: { table: { rows: [["Plan", null], ["Ship", "By June"]] } }\n```\n\nA payload is a block, a slide or region that holds one content field, or a group of metric blocks. `id`, `extensions`, `type` (kept in step with the new kind) and any slide fields (`title`, `design`, `notes`, ...) stay on the result.\n\n## Block conversions\n\n| From | To | Lossless? | What is lost or reported |\n| --- | --- | --- | --- |\n| text | list | when every line is plain, not blank and not numbered | `blank lines`; `list numbering` when `1.` markers are stripped. Indentation (2 spaces or a tab) and `-`, `*`, `\u2022`, `+`, `1.` markers become nesting levels; formatting is kept |\n| text | quote | when the text is plain | `text formatting`. A trailing dash line (`\u2014 Name, Title`, `\u2013`, `--`, `~`, `-`) is the attribution and a second one the source; a last line ``\u201CQuote\u201D \u2014 Name`` splits when the quote opens with a quote mark. Never guessed from a lone line, a dashed list or a line over 120 characters |\n| text | metric | when plain | `blank lines`, `text formatting`. Refused when the first line is over 24 characters; first line is the value, the second the label, the rest the description |\n| text | code | when plain | `text formatting`. One fenced block (` ``` ` or `~~~`) gives the source, the language (first word of the info string) and the file name (`title=`, `filename=`, `file=`); nothing is guessed |\n| text | timeline | when plain | `blank lines`, `text formatting`. Lines like `2024 \u2014 Launch`, `Q1 2026: Pilot`, `Jan - Kickoff`, `2026-03` give `when`; an indented line is the previous event\'s description; `looseWhen: true` also splits `Label: text` |\n| text | table | when plain | Markdown pipe tables (the separator row makes the headings), tab-separated lines, or `delimiter`/`header` options. Refused without that structure or with uneven rows |\n| list | text | when plain, no descriptions, levels in order | `list nesting levels (renumbered...)` for gaps in levels; `list item descriptions (kept as indented lines)`. Levels are written as 2-space indentation |\n| list | timeline | no when the list nests or is rich | `list nesting levels`, `text formatting`. `2024 \u2014 Launch` gives `when`; an item\'s description is the event\'s description |\n| list | table | yes without nesting | `list nesting levels`. One column, or text and description when any item has one; no headings are invented |\n| quote | text | yes | text, then `\u2014 attribution, role` (the role joins the attribution after a comma), then `\u2014 source` (the source line is plain when there is no attribution or role); loses the `quote photo` |\n| metric | text | no with a trend or sentiment | `metric trend`, `metric sentiment` |\n| code | text | yes with `fences: "auto"` (default) | A fenced block keeps the language and file name; with `fences: "never"` the loss is `code language`, `code filename` |\n| timeline | text | yes | `timeline name`, `timeline description` for the metadata a text cannot hold; `when: what`, then the description indented |\n| timeline | list | yes | `timeline name`, `timeline description`; `when: what` is the item text, the description its description |\n| timeline | table | yes | `timeline name`, `timeline description`. Columns `When`, `What`, `Description` only for the fields used |\n| chart | table | no | `chart type`. Only inline `columns` and `rows`, or a dataset; a chart without them is refused |\n| table | chart | yes | Refused unless every column has a plain label and every value after the first column is a number, a numeric string or empty; type `column` |\n| table | list | no | `column headings`; `table columns beyond the second (joined into the description)`; `cell styles`. First column is the item, the other columns the description. Merged cells are refused |\n| table | timeline | yes when the headings are recognised | Reads columns by heading (`When`/`Date`/`Quarter`..., `What`/`Event`/`Milestone`..., `Description`/`Notes`...) or by `columns: { when, what, description }`; `column "X"` for dropped columns, `column heading "X"` for a heading it did not recognise, `text formatting`. Refused without headings or an event column |\n| table | text | yes for plain cells | `text formatting`, `cell styles`, `merged cells`, `line breaks inside cells`, `empty rows`. Markdown with headings, tab-separated without |\n| table | metric blocks | yes when the headings are recognised | Needs a `Value` column; `Label`, `Unit`, `Delta`, `Trend`, `Sentiment`, `Description` are read too; `column "X"`, `trend values other than up, down or flat`, `sentiment values other than positive, negative or neutral`, `text formatting` |\n| group of metric blocks | table | yes | `block ids and extensions`, `group arrangement (composition)` (a group\'s composition cannot sit on a table; a slide keeps its own). Columns only for the fields used |\n\nImages, videos and any other group have no content conversion.\n\n## Other helpers\n\n| Function | What it does | Lossless? |\n| --- | --- | --- |\n| `demoteListItems`, `promoteListItems`, `shiftListLevels` | Nest an item one level deeper (Tab) or one level up (Shift+Tab). A level is at most one more than the item above and never below 0; the first item cannot nest. Items under it move with it unless `withChildren: false`. Level 0 collapses back to the plain form | yes; `changed: false` and a `reason` when nothing can move |\n| `convertListForm` | `items` to `bullets` and back | `list item descriptions` when going to bullets |\n| `wrapBlocks` | Put the selected blocks of a slide or group into a new group, optionally with a composition | yes |\n| `unwrapGroup` | Replace a group in a `blocks` list by its blocks | `group arrangement (composition)`, `group id`, `group extensions` |\n| `blocksToRegions`, `regionsToBlocks`, `moveRegion` | Move blocks into named regions (one block each, no overlap), turn regions back into blocks in reading order, move or swap a region | `region placement` going to blocks |\n| `promoteImage` | Move an image block into `design.background` (keeping its alt text, `fit`, `focus`, `opacity` and `overlay`) or `design.watermark` (`opacity`, default 0.1); refuses an occupied slot unless `replace: true`. Placing an image beside the content is not a move: set the block\'s `placement` | `image alt text` (watermark), the block\'s other treatments (`image shape`, `image placement`, ...), `image title`/`description`/`mediaType`/`format`, `block id` |\n| `demoteImage` | Move the background image or watermark back into the content as an image block, with the background\'s alt text, `fit`, `focus`, `opacity` and `overlay` | `background tile fit`, `watermark opacity` |\n| `splitSlide` | Split a slide by blocks (`at: [indexes]` or `each: true`); design, layout and the other slide fields are copied, headings repeat (`repeatHeadings`), the first slide keeps the id and notes, the others get free ids ending `--2`, `--3`. Regions split region by region | `region placement` for regions |\n| `splitSlideOnOverflow` | Split a slide that does not fit with the existing pagination (`paginate`, one slide as slide 1 of 1, host `options` for fonts and measurement) and return the `pages` mapping | yes |\n| `mergeSlides` | Merge consecutive slides: the first slide\'s id, headings and design win, blocks follow each other, notes are joined with a blank line | `slide id "x"`, `title of slide N`, any other field that differs; regions are refused |\n| `unpaginate` | The inverse of pagination for slides still as `paginate` returned them, given its `pages`: slices of text, items, rows, events and code are joined into the original leaves | yes (the pagination readability floor stays in `composition.minFontSize`); refused when the slices are not consecutive |\n\nSlide-level functions return the replacement slides, the replaced `range` (`start`, `deleteCount`) and the new `presentation`; structure functions return the new `slide` and the `path` of what they touched.\n\n## Decisions\n\n- Column headings of timeline and metric tables are the schema\'s own field names. They are the only generated text and can be omitted; reading a table back needs headings (or explicit `columns`), because a role is never guessed from position.\n- Text-to-timeline and text-to-quote parse only unambiguous patterns; the rest stays text. Parsing a pattern is not loss, and a wrong guess would be invented structure.\n- Code to text writes a fenced block when the code has a language or file name, so the round trip is lossless. A fence and its `title=` are the visible cost; `fences: "never"` writes the bare source and reports the loss.\n- Un-paginate needs the pagination mapping. Without it, `mergeSlides` concatenates blocks and never glues text together, because pagination\'s exact slice boundaries are not recoverable from the slides.\n'
56
56
  },
57
57
  {
58
58
  "slug": "data-import",
59
59
  "file": "docs/data-import.md",
60
60
  "title": "CSV and JSON data in OPF",
61
- "markdown": '# CSV and JSON data in OPF\n\nImport CSV, TSV, and JSON as ordinary inline tables or charts. The resulting OPF stays editable in the browser and works with PPTX export without needing the original file.\n\n## Editor\n\nClick **Import data** in the editor toolbar. Paste data or select a `.csv`, `.tsv`, or `.json` file. Choose Table or Chart, review the slide preview, and import. Charts let you choose the category column and numeric series. You can insert a new slide or replace a selected table/chart, including one inside a nested block. Imports are one undoable operation.\n\nThe first CSV/TSV row supplies column names by default. Uncheck that option for headerless data. JSON supports:\n\n- An array of records: `[{"Quarter":"Q1","Revenue":12},{"Quarter":"Q2","Revenue":18}]`.\n- A matrix with a header row: `[["Quarter","Revenue"],["Q1",12],["Q2",18]]`.\n- An explicit table: `{"columns":["Quarter","Revenue"],"rows":[["Q1",12],["Q2",18]]}`.\n\nAll record keys become columns in first-seen order. Missing record fields become null. Nested objects and arrays in cells must be flattened before import. Ragged rows, duplicate column names, malformed CSV, and invalid JSON produce errors.\n\nCSV table values stay strings, preserving identifiers such as `001` and exact input text. JSON scalar cell types are retained. Chart series convert strict numeric strings to numbers. Blank, null, boolean, currency-formatted, percentage-formatted, and ambiguous numeric values are rejected as measures; they are never silently replaced with zero. Numeric category labels remain categories. Choose one nonnegative series for pie/donut charts.\n\nThe strict rule is core `chartNumber` (RR-54): a finite number, or a trimmed string in plain decimal syntax (`12`, `-3.5`, `0.25`, `1e6`). `12%`, `$5`, `(5)`, `1,234`, `1.234,5` and `Q1` are not numbers. Import rejects them; in an existing chart the preview and the PPTX export plot them as gaps and the validator warns `chart-value-not-numeric`. Keep the stored value plain (0.31, not `31%`) and put the display in a column format, `{ "name": "Margin", "format": "0%" }`; see [Chart and table data](chart-table-data.md).\n\n### Datasets and provenance\n\nData that several charts or tables show can live once in the top-level `datasets` map and be referenced by id: `"chart": { "type": "line", "data": { "dataset": "revenue" } }`, `"table": { "dataset": "revenue", "fields": ["Quarter", "Revenue"] }`. A dataset (or inline chart data) records where it came from with `source`: `{ "src": "./data/revenue.csv", "sheet"?, "range"?, "fields"?, "retrieved": "2026-10-05", "description"? }`. Engines keep `source` through editing, export and re-import, and never read, fetch or refresh it; re-import to update the data.\n\nThe browser preview supports imported column, bar, line, area, pie, and donut charts, including multiple series for the first four types. Large tables or long labels can still need layout adjustments or pagination.\n\n## CLI\n\nUse the published [CLI 0.11.0](../packages/cli/README.md) on Node 24:\n\n```sh\nopf import-data revenue.csv --as table --output table.opf.json\nopf import-data revenue.json --as chart --chart-type line --output chart.opf.json\nopf import-data revenue.csv --as chart --category Quarter --series \'["Revenue","Costs"]\' --into deck.opf.json --in-place\nopf import-data revised.csv --as table --into deck.opf.json --path /slides/0/blocks/0/table --output reviewed.opf.json\nopf import-data revenue.csv --as chart --dataset revenue --into deck.opf.json --in-place\n```\n\n`--into` appends a new data slide unless `--path` names an existing content container\'s `/table` or `/chart` field. The parent must already exist. The complete resulting document must validate. Unrelated fields remain intact. Without `--output` or `--in-place`, the document goes to stdout for review or piping. Existing output files require `--force`.\n\nUse `--format csv|tsv|json` to override format detection, `--delimiter \';\'` for semicolon CSV, `--no-header` for row arrays without labels, `--columns \'["Quarter","Revenue"]\'` to select/reorder columns, and `--title` to name a new data slide. `--dataset <id>` (RR-54, in the CLI release after 0.10.0) writes the imported columns and rows into `datasets.<id>` (replacing an existing dataset\'s rows and columns and keeping its title, its description and the format of every column whose name is unchanged; `source` records the file, and keeps its other fields only when the same file is re-imported; data read from stdin records no `source`) and references it from the new table or chart instead of embedding a copy. `--series` and `--columns` accept JSON arrays so column names can contain commas. `-` reads data from stdin.\n\n## Package API\n\n```js\nimport {parseTabularData, importData} from \'@openpresentation/opf/data\';\n\nconst csv = \'Quarter,Revenue,Costs\\nQ1,12,8\\nQ2,18,10\';\nconst table = importData(csv, {as: \'table\', format: \'csv\'});\nconst chart = importData(csv, {\n as: \'chart\', format: \'csv\', chartType: \'line\',\n category: \'Quarter\', series: [\'Revenue\', \'Costs\'],\n});\nconst document = {slides: [{title: \'Quarterly data\', blocks: [table, chart]}]};\nconst data = parseTabularData(csv); // {columns, rows}, with CSV strings preserved\n```\n\nThe functions also accept already-parsed JSON and are re-exported by `@openpresentation/opf-editor/data`. They are synchronous and browser-safe. Hosts read files with `File.text()` or Node\'s file APIs and pass their contents in. Neither function fetches URLs, resolves asset references, or reads files automatically.\n\nThis is an embedded data snapshot, not a live file link. Record the origin with `source` (see above). OPF has no chart data source by file or asset (`"data": { "src": ... }`); it was removed because no engine loaded it ([opf#240](https://github.com/OpenPresentation/opf/issues/240) is descoped), and the validator rejects it. Tables use inline `columns`/`rows` or a dataset; there is no `table.src` field. Re-import after a source changes.\n\n`parseTabularData` and `importData` share the `@openpresentation/opf/data` entry with the chart and table data API: `chartNumber`, `formatDataNumber`, `toExcelNumberFormat`, `fromExcelNumberFormat`, `inlineDatasets`, `resolveChartData`, `resolveTableData` and `tableCellDisplayValue`.\n\nThese APIs are published in core 0.11.0 and re-exported by editor 0.8.0; CLI 0.10.0 and later include `import-data`. Use the coordinated Node 24 train with core 0.13.0, renderer 0.13.1, editor 0.12.1 and PPTX 0.13.2 for preview/export. Exact pins and compatibility boundaries are in the [compatibility matrix](compatibility-matrix.md) and [release plan](../release-plan.json).\n\n## Verification\n\n`node packages/javascript/test/data.mjs` checks parsing and mapping. `pnpm test:cli:packed` tests the installed CLI including data import. `pnpm test:data` verifies SVG series/signs and PPTX export/import. After `pnpm demo:editor`, open `/data-tests.html` on the editor server for file upload, preview, insertion, replacement, validation, and undo checks.\n'
61
+ "markdown": '# CSV and JSON data in OPF\n\nImport CSV, TSV, and JSON as ordinary inline tables or charts. The resulting OPF stays editable in the browser and works with PPTX export without needing the original file.\n\n## Editor\n\nClick **Import data** in the editor toolbar. Paste data or select a `.csv`, `.tsv`, or `.json` file. Choose Table or Chart, review the slide preview, and import. Charts let you choose the category column and numeric series. You can insert a new slide or replace a selected table/chart, including one inside a nested block. Imports are one undoable operation.\n\nThe first CSV/TSV row supplies column names by default. Uncheck that option for headerless data. JSON supports:\n\n- An array of records: `[{"Quarter":"Q1","Revenue":12},{"Quarter":"Q2","Revenue":18}]`.\n- A matrix with a header row: `[["Quarter","Revenue"],["Q1",12],["Q2",18]]`.\n- An explicit table: `{"columns":["Quarter","Revenue"],"rows":[["Q1",12],["Q2",18]]}`.\n\nAll record keys become columns in first-seen order. Missing record fields become null. Nested objects and arrays in cells must be flattened before import. Ragged rows, duplicate column names, malformed CSV, and invalid JSON produce errors.\n\nCSV table values stay strings, preserving identifiers such as `001` and exact input text. JSON scalar cell types are retained. Chart series convert strict numeric strings to numbers. Blank, null, boolean, currency-formatted, percentage-formatted, and ambiguous numeric values are rejected as measures; they are never silently replaced with zero. Numeric category labels remain categories. Choose one nonnegative series for pie/donut charts.\n\nThe strict rule is core `chartNumber` (RR-54): a finite number, or a trimmed string in plain decimal syntax (`12`, `-3.5`, `0.25`, `1e6`). `12%`, `$5`, `(5)`, `1,234`, `1.234,5` and `Q1` are not numbers. Import rejects them; in an existing chart the preview and the PPTX export plot them as gaps and the validator warns `chart-value-not-numeric`. Keep the stored value plain (0.31, not `31%`) and put the display in a column format, `{ "name": "Margin", "format": "0%" }`; see [Chart and table data](chart-table-data.md).\n\n### Datasets and provenance\n\nData that several charts or tables show can live once in the top-level `datasets` map and be referenced by id: `"chart": { "type": "line", "data": { "dataset": "revenue" } }`, `"table": { "dataset": "revenue", "fields": ["Quarter", "Revenue"] }`. A dataset (or inline chart data) records where it came from with `source`: `{ "src": "./data/revenue.csv", "sheet"?, "range"?, "fields"?, "retrieved": "2026-10-05", "description"? }`. Engines keep `source` through editing, export and re-import, and never read, fetch or refresh it; re-import to update the data.\n\nThe browser preview supports imported column, bar, line, area, pie, and donut charts, including multiple series for the first four types. Large tables or long labels can still need layout adjustments or pagination.\n\n## CLI\n\nUse the published [CLI 0.11.0](../packages/cli/README.md) on Node 24:\n\n```sh\nopf import-data revenue.csv --as table --output table.opf.json\nopf import-data revenue.json --as chart --chart-type line --output chart.opf.json\nopf import-data revenue.csv --as chart --category Quarter --series \'["Revenue","Costs"]\' --into deck.opf.json --in-place\nopf import-data revised.csv --as table --into deck.opf.json --path /slides/0/blocks/0/table --output reviewed.opf.json\nopf import-data revenue.csv --as chart --dataset revenue --into deck.opf.json --in-place\n```\n\n`--into` appends a new data slide unless `--path` names an existing content container\'s `/table` or `/chart` field. The parent must already exist. The complete resulting document must validate. Unrelated fields remain intact. Without `--output` or `--in-place`, the document goes to stdout for review or piping. Existing output files require `--force`.\n\nUse `--format csv|tsv|json` to override format detection, `--delimiter \';\'` for semicolon CSV, `--no-header` for row arrays without labels, `--columns \'["Quarter","Revenue"]\'` to select/reorder columns, and `--title` to name a new data slide. `--dataset <id>` (RR-54, in the CLI release after 0.10.0) writes the imported columns and rows into `datasets.<id>` (replacing an existing dataset\'s rows and columns and keeping its title, its description and the format of every column whose name is unchanged; `source` records the file, and keeps its other fields only when the same file is re-imported; data read from stdin records no `source`) and references it from the new table or chart instead of embedding a copy. `--series` and `--columns` accept JSON arrays so column names can contain commas. `-` reads data from stdin.\n\n## Package API\n\n```js\nimport {parseTabularData, importData} from \'@openpresentation/opf/data\';\n\nconst csv = \'Quarter,Revenue,Costs\\nQ1,12,8\\nQ2,18,10\';\nconst table = importData(csv, {as: \'table\', format: \'csv\'});\nconst chart = importData(csv, {\n as: \'chart\', format: \'csv\', chartType: \'line\',\n category: \'Quarter\', series: [\'Revenue\', \'Costs\'],\n});\nconst document = {slides: [{title: \'Quarterly data\', blocks: [table, chart]}]};\nconst data = parseTabularData(csv); // {columns, rows}, with CSV strings preserved\n```\n\nThe functions also accept already-parsed JSON and are re-exported by `@openpresentation/opf-editor/data`. They are synchronous and browser-safe. Hosts read files with `File.text()` or Node\'s file APIs and pass their contents in. Neither function fetches URLs, resolves asset references, or reads files automatically.\n\nThis is an embedded data snapshot, not a live file link. Record the origin with `source` (see above). OPF has no chart data source by file or asset (`"data": { "src": ... }`); it was removed because no engine loaded it ([opf#240](https://github.com/OpenPresentation/opf/issues/240) is descoped), and the validator rejects it. Tables use inline `columns`/`rows` or a dataset; there is no `table.src` field. Re-import after a source changes.\n\n`parseTabularData` and `importData` share the `@openpresentation/opf/data` entry with the chart and table data API: `chartNumber`, `formatDataNumber`, `toExcelNumberFormat`, `fromExcelNumberFormat`, `inlineDatasets`, `resolveChartData`, `resolveTableData` and `tableCellDisplayValue`.\n\nThese APIs are published in core 0.11.0 and re-exported by editor 0.8.0; CLI 0.10.0 and later include `import-data`. Use the coordinated Node 24 train with core 0.14.0, renderer 0.14.0, editor 0.14.2 and PPTX 0.14.0 for preview/export. Exact pins and compatibility boundaries are in the [compatibility matrix](compatibility-matrix.md) and [release plan](../release-plan.json).\n\n## Verification\n\n`node packages/javascript/test/data.mjs` checks parsing and mapping. `pnpm test:cli:packed` tests the installed CLI including data import. `pnpm test:data` verifies SVG series/signs and PPTX export/import. After `pnpm demo:editor`, open `/data-tests.html` on the editor server for file upload, preview, insertion, replacement, validation, and undo checks.\n'
62
62
  },
63
63
  {
64
64
  "slug": "default-catalog",
65
65
  "file": "docs/default-catalog.md",
66
- "title": "The default catalog",
67
- "markdown": '# The default catalog\n\nEvery OPF catalog reference resolves through the same chain: inline\n`catalogs.<kind>.records[]`, then `catalogs.<kind>.source`, then engine defaults,\nthen the **default catalog** at `https://www.pptx.gallery/<kind>`. The\nreferencing fields are `narrative`, `language`, `tone`, `audience`, `purpose`,\n`design.theme`, `design.colorScheme`, `design.fontScheme`, `Slide.layout`,\n`Chart.type` and the platform keys in `socials`.\n\nThis page defines who publishes that catalog, how to fetch it, and how the copy\nbundled in `@openpresentation/opf` stays tied to it.\n\n## Contract\n\n- **pptx.gallery publishes the catalog; core is the source of truth for its\n content.** Since the FF-37 decision (2026-10-02) a record can change here first:\n the gallery\'s CI checks its published files against the `@openpresentation/opf`\n release in its lockfile (`pnpm check:core-catalog`) and adopts the change with\n the next core release. A change that begins in the gallery still lands through\n the sync below.\n- **`spec/catalogs/` is a pinned snapshot.** `spec/catalogs/manifest.json`\n records the gallery commit and a content hash per kind.\n- **Engines never fetch at run time by default.** Renderers, exporters,\n validators and the CLI resolve the default catalog from the bundled snapshot,\n so resolution is deterministic offline and in the cloud.\n- **A declared `catalogs.<kind>.source` is an opt-in.** The engine\'s caller\n performs that fetch; the OPF packages do not.\n\n## Endpoints\n\n| Request | Response |\n| --- | --- |\n| `GET https://www.pptx.gallery/<kind>/index.json` | Catalog index |\n| `GET https://www.pptx.gallery/<kind>` with `Accept: application/json` | Same catalog index |\n| `GET https://www.pptx.gallery/<kind>/<id>.json` | One record |\n| `GET https://www.pptx.gallery/<kind>/<id>` with `Accept: application/json` | Same record |\n| Either URL from a browser | The gallery\'s HTML page |\n\n`/<kind>/index.json` is the stable explicit alias. It is the index-file form that\nthe `CatalogSource` contract already defines, so both\n`"source": "https://www.pptx.gallery/tones"` (directory form, records at\n`<base>/<id>.json`) and `"source": "https://www.pptx.gallery/tones/index.json"`\n(index form) address the published catalog. Catalog files are served with\n`Access-Control-Allow-Origin: *`, and negotiated URLs send `Vary: Accept`.\n\nThe older `https://www.pptx.gallery/api/<dimension>.json` envelopes carry the\ngallery\'s presentation data in the gallery\'s own shape. They stay\nbackward compatible, but they are not OPF records. Each one now links its\ncatalog index with `Link: <\u2026/<kind>/index.json>; rel="alternate"`.\n\n## Kinds and URL mapping\n\nThe URL segment is the one each `Catalogs` property names as its default source\nin `spec/schemas/opf.schema.json`. It is also the `spec/catalogs/<kind>`\ndirectory name.\n\n| `<kind>` | `catalogs.<key>` | Record schema | Referenced from | Gallery page | Snapshot mode |\n| --- | --- | --- | --- | --- | --- |\n| `audiences` | `audiences` | `opf-audience/v1` | `audience` | `/audiences` | subset |\n| `chart-types` | `chartTypes` | `opf-chart-type/v1` | `Chart.type` | `/charts` | subset |\n| `color-schemes` | `colorSchemes` | `opf-color-scheme/v1` | `design.colorScheme` | `/colors` | mirror |\n| `font-schemes` | `fontSchemes` | `opf-font-scheme/v1` | `design.fontScheme` | `/font-schemes` | mirror |\n| `languages` | `languages` | `opf-language/v1` | `language` | `/languages` | mirror |\n| `layouts` | `layouts` | `opf-layout/v1` | `Slide.layout` | `/layouts` | subset |\n| `narratives` | `narratives` | `opf-narrative/v1` | `narrative` | `/narratives` | subset |\n| `purposes` | `purposes` | `opf-purpose/v1` | `purpose` | none yet | mirror |\n| `social-platforms` | `socialPlatforms` | `opf-social-platform/v1` | `socials` keys | `/socials` | mirror |\n| `themes` | `themes` | `opf-theme/v1` | `design.theme` | `/themes` | mirror |\n| `tones` | `tones` | `opf-tone/v1` | `tone` | `/tones` | mirror |\n\nRecord schema ids are `https://openpresentation.org/schema/<name>`.\n\n## Index and record shape\n\nAn index validates against `spec/schemas/catalog-index.schema.json`:\n\n```json\n{\n "$schema": "https://openpresentation.org/schema/opf-catalog-index/v1",\n "kind": "tones",\n "version": "1",\n "description": "\u2026",\n "contentSha256": "b5a8d093\u2026",\n "records": [{ "id": "formal", "name": "Formal", "summary": "\u2026", "file": "formal.json" }]\n}\n```\n\n- `records` is in canonical order. `file` is relative to the index.\n- `version` is the index format version.\n- `contentSha256` is the lowercase hex SHA-256 of the canonical JSON of the full\n records in index order, with every top-level `x-*` member removed. Canonical\n JSON sorts object keys and has no insignificant whitespace\n (`canonicalJson()` in `scripts/catalog-snapshot.mjs`). The bundled index and\n the published index carry the same value for a mirrored kind.\n\nEach record validates against its kind\'s companion schema and names it in\n`$schema`. Publishers may add top-level `x-*` extension members. pptx.gallery\nputs its presentation metadata (page URL, mood tags, contrast notes, font stacks)\nin `x-gallery`. Consumers ignore `x-*` members, and the snapshot never carries\nthem.\n\n## Deprecation\n\nThe bundled catalog holds **no deprecated records**. Before v1 an id is renamed or\nremoved outright (the FA-03 cleanup deleted 50 chart-type aliases and 6 audience\naliases and dropped the `-3x` suffix from the chart-type ids), so a document that\nnames a retired id gets an `unknown <kind> catalog id` warning, not a redirect.\n\nThe mechanism stays in the record schemas and in `validate` for after v1.\nAny record may carry `deprecation: { "replacedBy": "<id>", "reason"?, "removal"? }`\n(FF-22). The record stays for backward compatibility:\n\n- the old id keeps resolving to its own record, unchanged;\n- `validate` warns with `opf/deprecated-catalog-id` (`Deprecated <kind> catalog id "<id>"; use "<replacedBy>" instead.`) and suggests the replacement;\n- pickers and generators should offer only non-deprecated records. Index entries\n carry `"deprecated": true` and `replacedBy`, so a picker can hide the old id\n without loading records.\n\n`check:spec` requires the replacement to be a bundled record of the same kind\nthat is not deprecated itself: rule (f) for chart types, rule (h) for every\nother kind. Inline `catalogs.<kind>.records` may use the same field.\n\n## The snapshot\n\n`spec/catalogs/manifest.json` (schema `spec/schemas/catalog-manifest.schema.json`):\n\n```json\n{\n "publisher": "https://www.pptx.gallery",\n "source": { "repository": "https://github.com/Data-Advantage/pptx-gallery", "commit": "<sha>", "path": "public" },\n "kinds": {\n "tones": { "mode": "mirror", "records": 7, "contentSha256": "\u2026", "gallery": { "records": 7, "contentSha256": "\u2026" } }\n }\n}\n```\n\n- **mirror**: the snapshot holds every published record of the kind.\n- **subset**: the snapshot keeps the ids it already bundles, with content taken\n from the publisher, while the publisher also serves records that are not\n reconciled for bundling yet (for example the gallery\'s extra layouts).\n\nThe snapshot never loses an id. Removing a catalog record is a breaking change,\nso the sync refuses a publisher that stopped serving a bundled id. The one waiver\nis explicit and per id: `--allow-removed <kind>:<id>[,<id>...]` (repeatable) lets\nthe sync drop exactly those ids from the snapshot and delete their files, and\nrejects an id the snapshot does not hold. It exists for removals and renames\ndecided on purpose before v1 (the FA-03 chart-type cleanup), for example when the\ngallery adopts a core change first:\n\n```sh\nnode scripts/sync-gallery-catalog.mjs --gallery ../pptx-gallery --allow-removed chart-types:<old-id> --include chart-types:<new-id>\n```\n\nA core-first removal needs no waiver: delete the records and index entries, then\nrun `--rehash`.\n\n### Updating it\n\nEither side can start a change. To start in core (a deprecation, a wording\nfix), edit the records and index entries under `spec/catalogs/<kind>/`, then\nrewrite the hashes and counts:\n\n```sh\nnode scripts/sync-gallery-catalog.mjs --rehash # index contentSha256, manifest records and contentSha256\n```\n\n`--rehash` leaves the manifest `source` and each kind\'s `gallery` block alone,\nbecause they describe the pinned gallery commit, and it does not touch `mode`. A\nmirrored kind must also match the gallery hash, so a core-first change to a mirror\nkind needs the gallery to publish it first, or the kind to move to `subset`; when the gallery change that\npublishes the same records is already in review, `--rehash --match-gallery` also sets that kind\'s `gallery`\nblock to the new hash. The\ngallery adopts a core-first change with the next `@openpresentation/opf` release\n(its `check:core-catalog` reads that release).\n\nTo start in the gallery:\n\n```sh\n# in the pptx-gallery checkout: edit data/, then\npnpm build:opf-catalog # regenerate and validate public/<kind>/\ngit commit # the snapshot pins a commit\n\n# in this repository\nnode scripts/sync-gallery-catalog.mjs --gallery ../pptx-gallery # writes spec/catalogs + manifest\nnode scripts/sync-gallery-catalog.mjs --gallery ../pptx-gallery --report # per-kind counts and gallery-only ids\n```\n\nThe sync validates every published index and record against the schemas in\n`spec/schemas/`, checks the published `contentSha256`, drops `x-*` members, and\nrewrites only the files whose content changed. Writes require a clean gallery\ncheckout so the manifest commit identifies the actual catalog bytes.\n`--url https://www.pptx.gallery` reads the live site for inspection and requires\n`--check` or `--report`; a live response cannot prove a source commit.\nDirty checkout inspection also stays read-only: `--allow-dirty` is accepted only\nwith `--check` or `--report` and cannot bypass the write guard.\n\nTo bundle more of a subset kind, reconcile it in the gallery first, then change\nits `mode` to `mirror` in the manifest and re-run the sync. To bundle only some of\nthe published ids, pass them once with `--include <kind>:<id>[,<id>...]`\n(repeatable); the snapshot keeps them from then on, like every bundled id, and\nthe sync reports an id the gallery does not publish. The layouts snapshot uses\nthis for the 70 legacy gallery slugs (FF-55): it holds 100 of the gallery\'s 485\nlayouts, and the rest stay gallery-only.\n\n### Layouts stay a subset by design (RR-41, opf#292)\n\n`layouts` is a permanent `subset`, decided on 2026-10-02 (vetoable by the owner).\nThe other 385 layouts (the Dark master; 24 of them deprecated aliases from FF-52)\nare published only by pptx.gallery. A document names one of them and resolves it\nonline through the default catalog, or offline with an inline\n`catalogs.layouts.records` entry, which the gallery snippets add and\n`bundle` inlines for the 100 bundled ids. All 485 compose, validate\nand export; this decision is about where the records live, not about the engines.\n\nMeasured on `@openpresentation/opf` 0.12.0 with all 485 layouts synced (`npm pack\n--dry-run`, then minified esbuild browser bundles of the published `opf-render`\n0.12.0 against each core build, and of the gallery editor playground at the\n`opf-editor` 0.11.1 release commit):\n\n| | 100 layouts (now) | 485 layouts | Change |\n| --- | ---: | ---: | ---: |\n| Packed tarball | 2,925,351 B | 2,979,596 B | +54,245 B (+1.9%) |\n| Unpacked | 9,177,405 B | 10,327,874 B | +1,150,469 B (+12.5%) |\n| Files | 645 | 1,030 | +385 |\n| `opf-render` bundle (minified / gzip) | 1,186,297 / 307,353 B | 1,568,259 / 326,443 B | +381,962 / +19,090 B (+32% / +6.2%) |\n| Gallery editor playground bundle (minified / gzip) | 3,656,803 / 1,207,310 B | 4,038,759 / 1,225,176 B | +381,956 / +17,866 B (+10.4% / +1.5%) |\n\nThe packed growth is small (gzip compresses the repetitive records). The bundle\ngrowth is not: `opf-render`, `opf-editor` and `opf-pptx` never import\n`@openpresentation/opf/catalogs` and never read a layout record from the bundled\ncatalog, but `composition`, `validator`, `pagination` and `convert` all reach the\none generated catalogs chunk, which a bundler cannot tree-shake, so every browser\nbundle would carry about 382 KB more for data it does not use. The catalog is not\nlazily loadable today. The supervisor rule was to bundle only when the packed\ncore grows by less than about 1.5 MB and the bundles do not meaningfully grow;\nthe second condition fails, so the subset is kept on purpose. Narrative layout\nhints do not need the rest: the FF-28 beat table references 17 gallery layouts,\n13 of them already bundled, and the other four (`text-1x-left`, `title-left`,\n`title-center`, `list-2x-title-center`) can be added with `--include` if the\nhints are restored.\n\nTo revisit: split the generated catalogs module per kind (or load it lazily) so a\nconsumer that does not read layouts does not carry them. After that, bundling all\n485 costs about 54 KB of packed size and nothing in the browser bundles, and the\nkind can be switched to `mirror` with a core release.\n\n### Checks\n\n- `pnpm check:spec` and `pnpm check:catalog` (both in `pnpm test`) verify offline\n that every kind\'s records still hash to the value in its index and the\n manifest. A hand edit to `spec/catalogs/` fails here until `--rehash` (core\n first) or the sync (gallery first) has rewritten them.\n- Drift between the gallery and this snapshot is checked on the gallery side\n (FF-37): pptx-gallery\'s `pnpm check:core-catalog` compares its\n published `public/<kind>/` files with `spec/catalogs` of the\n `@openpresentation/opf` release it depends on, in its own CI. Core is the source\n of truth and the package is public, so no secret is needed. The core CI no longer\n reads the private gallery. `sync-gallery-catalog.mjs --check` still compares a\n local gallery checkout when you sync.\n\n## Reconciliation status\n\nThe per-kind divergence between the gallery and this snapshot, and the plan for\nthe subset kinds, is in\n[`programs/font-fidelity-everywhere/ff-37-catalog-divergence.md`](programs/font-fidelity-everywhere/ff-37-catalog-divergence.md).\n'
66
+ "title": "Catalogs and the default catalog",
67
+ "markdown": '# Catalogs and the default catalog\n\nOPF 0.15 keeps catalogs out of the engine. A document embeds every record it uses, hosts register the catalogs they\ntrust, and core ships no records in its main entry. pptx.gallery publishes the **default catalog**; core carries a\npinned snapshot of it only as the opt-in subpath `@openpresentation/opf/catalog`.\n\nThis page is the contract: the document shape, the one resolution rule, how hosts register catalogs, the authoring\nhelpers, the diagnostics, and how the snapshot stays tied to pptx.gallery. The design behind it is\n[0.15-design.md](programs/format-audit/0.15-design.md).\n\n## Catalogs in a document\n\n`catalogs` groups the records a document embeds by the catalog they came from. Inside a group, records are keyed by\nkind and then by id:\n\n```json\n"catalogs": {\n "default": {\n "source": "https://www.pptx.gallery",\n "layouts": { "two-column": { "name": "Two column", "placeholders": [{ "type": "title" }, { "type": "text" }, { "type": "text" }] } },\n "themes": { "minimal": { "name": "Minimal", "colorScheme": "cool-horizon", "fontScheme": "aptos" } }\n },\n "acme": { "source": "pkg:@acme/opf-catalog", "layouts": { "hero": { "name": "Hero", "placeholders": [{ "type": "title" }] } } },\n "custom": { "layouts": { "q4-special": { "name": "Q4 special", "placeholders": [{ "type": "title" }, { "type": "chart" }] } } }\n}\n```\n\n- **`default`** is the catalog bare ids come from. It needs a `source` when it holds records. Omitted: bare ids fall\n back to the host\'s default catalog. `"default": false`: no catalog fallback at all, so every bare id must be embedded.\n- **`custom`** holds the records the document defines itself. It has no source. Forking a catalog record copies it here\n under a new id, so the change of ownership is visible.\n- **Any other name** (lowercase kebab-case) is an extra catalog, identified by its `source`: an HTTPS URL or a `pkg:`\n package reference. The name is the prefix its references use (`acme:hero`).\n- The kinds are `layouts`, `themes`, `colorSchemes`, `fontSchemes`, `narratives`, `audiences`, `purposes` and `tones`.\n- An embedded record has the fields of its kind\'s companion schema without `$schema` and `id` (the key is the id), and\n without the catalog\'s `x-*` display metadata. `validate` checks each one against the companion schema\n (`opf/catalog-record`).\n\n## References\n\nEvery content reference is a bare `id` or `name:id`, and every prefix must name a group (`opf/undeclared-catalog`\notherwise, a format error that engines reject at their format check). URLs and `pkg:` strings are not references; a named group replaces them. The references are\n`Slide.layout`, `design.theme`, `design.colorScheme` (or its `id`), `design.fontScheme` (or its `id`), the same three on\n`Slide.design`, `narrative`, `audience`, `purpose` and `tone` (a string or an object\'s `id`), a theme record\'s\n`colorScheme` and `fontScheme`, and a `language` object\'s `fontScheme` and `googleFontScheme`. An `audience` or\n`purpose` string that is not an id (it has spaces, capitals or punctuation) is free text, never looked up.\n\n## Resolution\n\nOne rule for every kind; nothing is ever fetched:\n\n1. A bare id: `catalogs.custom`, then the records embedded under `catalogs.default`, then the catalog the host\n registered for `default.source` (the host\'s default catalog when `default` is omitted; nothing when it is `false`).\n2. `name:id`: the records embedded under `catalogs.<name>`, then the catalog the host registered for its `source`.\n3. A reference written inside an embedded record (a theme\'s `colorScheme`) resolves in that record\'s own group first,\n so acme\'s `brand` theme finds acme\'s `ocean` before the default catalog\'s.\n4. Otherwise the reference is **unresolved**: the `opf/unresolved-reference` warning names the reference and the\n source it was looked for in, a slide composes automatically, and a design uses the engine default. A strict export\n fails with the same reference.\n\nA record embedded under `catalogs.default` or a named group whose registered catalog has no record of that kind and id is the warning `opf/catalog-record-not-in-source`: it is the document\'s own record and belongs in `catalogs.custom`. The check is silent when no catalog is registered for the group\'s source, for a record the catalog has with other content (an update difference) and for `custom`; rendering is unchanged.\n\nA slide with no `layout` is automatic composition, not a missing reference, and has no finding. Engines never\nsubstitute a different layout for one that does not resolve.\n\n## Registering catalogs\n\nEvery entry point that resolves references takes the same option, `catalogs`: the catalogs the host registered,\nmatched by `source`.\n\n> **The host-default rule.** The first registered catalog is the default for documents that omit `catalogs.default`:\n> their bare ids resolve in `custom`, then in that catalog. A document whose `default` names a `source` uses the\n> registered catalog with that source, and nothing when the host did not register it, even if it registered others.\n> `"default": false` uses no catalog for bare ids. Without the `catalogs` option nothing is registered: core has no\n> fallback of its own, so only what the document embeds resolves. Hosts (the CLI, the editor app, the sites) register\n> `@openpresentation/opf/catalog`; libraries and engines pass the option through.\n\n```js\nimport { validate, resolveSlideContext, paginate, embed } from \'@openpresentation/opf\';\nimport { defaultCatalog } from \'@openpresentation/opf/catalog\';\n\nconst catalogs = [defaultCatalog, acmeCatalog]; // acmeCatalog = { source: \'pkg:@acme/opf-catalog\', layouts: { hero: { \u2026 } } }\nvalidate(document, { catalogs });\nresolveSlideContext(document, 0, { catalogs, strictReferences: true }); // throws OPFUnresolvedReferenceError\npaginate(document, { catalogs });\n```\n\nA registered catalog has the shape of a document group with `source` required: `{ source, layouts?: { <id>: record },\nthemes?, \u2026 }`. Records may keep their `x-*` display metadata there; embedding strips it. `resolveSlideContext`,\n`paginate`, `validate`, `stats`, `resolveScriptFonts` (`/composition`), `resolveReference`, `catalogRecords`, `embed`,\n`copySlides`, `moveToCustom` and `updateFromCatalog` all take it, and opf-render, opf-pptx and opf-editor pass it through to core, so a\nrecord a host registers resolves the same in preview, editor and export. Without it only what the document embeds\nresolves. A `catalogs` value that is not an array of registered catalogs throws `OPFCatalogsOptionError`\n(`code: "invalid-catalogs"`) at every entry point. `resolveSlideContext(...).resolved.provenance` (and\n`resolveDesignRecords(...).provenance`) says where each resolved layout, theme, colour scheme and font scheme came from\n(`{ kind, reference, id, group, source?, origin }`, origin `document` or `host`), so engines record provenance without\nresolving again.\n\n`@openpresentation/opf/catalog` exports:\n\n| Export | What it is |\n| --- | --- |\n| `defaultCatalog` | The snapshot\'s content records, keyed by kind and id, registered under `DEFAULT_CATALOG_SOURCE` |\n| `DEFAULT_CATALOG_SOURCE` | `https://www.pptx.gallery` |\n| `catalogDisplay` | Display metadata for pickers: `chartTypes`, `languages` and `socialPlatforms` by id |\n| `catalogIndexes` | Each kind\'s `index.json` from the snapshot |\n| `layoutPreviews`, `layoutPreviewIndex`, `getLayoutPreview`, `hasLayoutPreview` | The static HTML layout previews |\n\nNo other entry of the package imports catalog data; `pnpm check:catalog-free` holds that with an esbuild metafile\nbudget.\n\n## Authoring helpers\n\n- **`embed(document, { catalogs })`** embeds each referenced record once, in the group it resolves in, together with\n the records it references (a theme\'s colour and font schemes). A bare id from the host default goes under\n `catalogs.default` with the catalog\'s source. Records already embedded are kept as they are; `x-*`, `$schema` and `id`\n are stripped. It returns `{ document, added, unresolved }` and is idempotent. Authoring tools call it on save and\n export; `opf embed` does it on the command line.\n- **`copySlides(from, to, indexes, { catalogs, at })`** copies slides with their records. Groups match by `source`, not\n by name: references are rewritten to the target\'s name for that source, and a missing group is added (renamed\n `<name>-2` when its name is taken). A source document without `catalogs.default` inherits the host default\'s\n source; when the target\'s default has another source, or is `false`, that catalog is added to the target as a named\n group and the copied bare references get its prefix, so copied slides never change catalog. A record the target\n already has with the same content is reused. A `custom` id\n the target uses for different content is renamed `<id>-2`. A catalog record whose revision differs from the one the\n target resolves moves into `custom` as `<id>-2`, so the copied slides look the same, and is listed in `renamed`.\n `renamed` lists only the records a call creates under a new id: a later copy that reuses one is not a rename.\n- **`moveToCustom(document, { kind, reference }, { catalogs })`** moves a record embedded under `default` or a named\n group into `custom`: the fix `opf/catalog-record-not-in-source` suggests. Every reference that named it is rewritten\n to name it in `custom`, and its own references keep naming what they named. It keeps its id unless `custom` holds a\n different record under it (then `<id>-2` and up, reported in `renamed`; an identical one is reused). It returns\n `{ document, from, to, references, patch, renamed? }`, where `patch` is RFC 6902 for review and undo, and throws\n `OPFMoveToCustomError` (`invalid-reference`, `already-custom`, `not-embedded`, `invalid-id`). Fork:\n `moveToCustom(document, ref, { id })` copies the record into `custom` under the new id (`<id>-2` on conflict) and\n rewrites every reference to the copy, qualified references inside other records included, so the original is dropped\n from its group; an editor calls it on the first edit of a catalog record and applies the edit to the copy.\n- **`updateFromCatalog(document, catalogs, refs?)`** compares the records embedded under `default` and the named groups\n with the registered catalogs\' current ones and returns `{ changes, patch }`. Nothing changes until the author applies\n `patch` (`applyPatch`); `custom` records are never compared.\n- **`resolveReference`**, **`parseReference`** and **`catalogRecords`** (the records a picker can offer, with the\n reference to write for each) are the building blocks.\n\n## Engine vocabularies and engine defaults\n\nValues an engine must understand to draw are not catalog references:\n\n- `chart.type` is a schema enum;\n- the keys of `Organization.socials` and `Speaker.socials` are a schema enum, linked with URL patterns in code;\n- `language` is a BCP-47 tag (or a `Language` object with a `bcp47` tag); engines know the script, direction, OOXML\n culture tag and default script fonts of the vocabulary\'s tags and infer the script of any other well-formed tag.\n\nThey are listed in [`spec/reference/engine-vocabularies.json`](../spec/reference/engine-vocabularies.json) and exported\nas `CHART_TYPES`, `SOCIAL_PLATFORMS` and `LANGUAGES`. Their labels, descriptions and icons stay in the catalog as\ndisplay metadata (`catalogDisplay`).\n\nWhen nothing names a theme, colour scheme or font scheme, or a reference resolves nowhere, every engine draws with the\nsame engine defaults: [`spec/reference/engine-defaults.json`](../spec/reference/engine-defaults.json), exported as\n`ENGINE_DEFAULT_THEME`, `ENGINE_DEFAULT_COLOR_SCHEME` and `ENGINE_DEFAULT_FONT_SCHEME`. They are the drawing fields of\nthe gallery\'s `minimal`, `cool-horizon` and `aptos` records, compiled into code.\n\n## Ownership\n\n- **pptx.gallery owns every catalog record** and publishes it; each id has one owner. Its display metadata lives under\n `x-*` members.\n- **`spec/catalogs/` is a pinned, one-way snapshot.** `spec/catalogs/manifest.json` records the gallery commit and a\n content hash per kind. Records change in the gallery and reach core through the sync below; a record rewrite that the\n 0.15 spec itself requires lands in the snapshot and in the gallery in the same release train.\n- **A catalog release never needs a core release.** Hosts register the catalog version they choose; documents carry\n the records they were saved with.\n\n## Endpoints\n\n| Request | Response |\n| --- | --- |\n| `GET https://www.pptx.gallery/<kind>/index.json` | Catalog index |\n| `GET https://www.pptx.gallery/<kind>` with `Accept: application/json` | Same catalog index |\n| `GET https://www.pptx.gallery/<kind>/<id>.json` | One published record |\n| `GET https://www.pptx.gallery/<kind>/<id>` with `Accept: application/json` | Same record |\n| Either URL from a browser | The gallery\'s HTML page |\n\nEngines never call these. A host may, to build the catalog it registers. Catalog files are served with\n`Access-Control-Allow-Origin: *`, and negotiated URLs send `Vary: Accept`. The older\n`https://www.pptx.gallery/api/<dimension>.json` envelopes carry the gallery\'s presentation data in its own shape; they\nare not OPF records and link their catalog index with `Link: <\u2026/<kind>/index.json>; rel="alternate"`.\n\n## Kinds\n\n| `<kind>` (URL and snapshot directory) | Key | Record schema | Role | Snapshot mode |\n| --- | --- | --- | --- | --- |\n| `audiences` | `audiences` | `opf-audience/v1` | content, `audience` | mirror |\n| `color-schemes` | `colorSchemes` | `opf-color-scheme/v1` | content, `design.colorScheme` | mirror |\n| `font-schemes` | `fontSchemes` | `opf-font-scheme/v1` | content, `design.fontScheme` | mirror |\n| `layouts` | `layouts` | `opf-layout/v1` | content, `Slide.layout` | mirror |\n| `narratives` | `narratives` | `opf-narrative/v1` | content, `narrative` | mirror |\n| `purposes` | `purposes` | `opf-purpose/v1` | content, `purpose` | mirror |\n| `themes` | `themes` | `opf-theme/v1` | content, `design.theme` | mirror |\n| `tones` | `tones` | `opf-tone/v1` | content, `tone` | mirror |\n| `chart-types` | `chartTypes` | `opf-chart-type/v1` | display metadata for `chart.type` | mirror |\n| `languages` | `languages` | `opf-language/v1` | display metadata for `language` | mirror |\n| `social-platforms` | `socialPlatforms` | `opf-social-platform/v1` | display metadata for `socials` keys | mirror |\n\nRecord schema ids are `https://openpresentation.org/schema/<name>`. A font scheme\'s `languages` list holds BCP-47 tags.\n\n## Index and record shape\n\nAn index validates against `spec/schemas/catalog-index.schema.json`:\n\n```json\n{\n "$schema": "https://openpresentation.org/schema/opf-catalog-index/v1",\n "kind": "tones",\n "version": "1",\n "description": "\u2026",\n "contentSha256": "b5a8d093\u2026",\n "records": [{ "id": "formal", "name": "Formal", "summary": "\u2026", "file": "formal.json" }]\n}\n```\n\n- `records` is in canonical order. `file` is relative to the index.\n- `version` is the index format version.\n- `contentSha256` is the lowercase hex SHA-256 of the canonical JSON of the full records in index order, with every\n top-level `x-*` member removed. Canonical JSON sorts object keys and has no insignificant whitespace (`canonicalJson()`\n in `scripts/catalog-snapshot.mjs`). The bundled index and the published index carry the same value for a mirrored kind.\n\nA **published record file** names its companion schema in `$schema` and carries its `id`; `validateCatalogRecord(kind,\nrecord)` checks one. Publishers may add top-level `x-*` members (pptx.gallery puts page URLs, mood tags, contrast\nnotes and font stacks in `x-gallery`); the snapshot never carries them. The copy a document embeds has neither\n`$schema` nor `id` nor `x-*` members.\n\nBefore v1 an id is renamed or removed outright: there are no deprecated records, aliases or redirects. A document that\nnames a retired id gets `opf/unresolved-reference`.\n\n## The snapshot\n\n`spec/catalogs/manifest.json` (schema `spec/schemas/catalog-manifest.schema.json`):\n\n```json\n{\n "publisher": "https://www.pptx.gallery",\n "source": { "repository": "https://github.com/Data-Advantage/pptx-gallery", "commit": "<sha>", "path": "public" },\n "kinds": {\n "tones": { "mode": "mirror", "records": 7, "contentSha256": "\u2026", "gallery": { "records": 7, "contentSha256": "\u2026" } }\n }\n}\n```\n\nEvery kind is `mirror`: the snapshot holds every record the gallery publishes (all 278 layouts, for example), so\n`@openpresentation/opf/catalog` is the full gallery catalog and a host registers it as is. It costs nothing in the\nengine bundles: only `/catalog` carries the snapshot, and `pnpm check:catalog-free` budgets it on its own.\n\nThe snapshot never loses an id by accident: the sync refuses a publisher that stopped serving a held id. The one waiver\nis explicit and per id: `--allow-removed <kind>:<id>[,<id>...]` (repeatable) drops exactly those ids and deletes their\nfiles.\n\n### Updating it\n\n```sh\n# in the pptx-gallery checkout: edit data/, then\npnpm build:opf-catalog # regenerate and validate public/<kind>/\ngit commit # the snapshot pins a commit\n\n# in this repository\nnode scripts/sync-gallery-catalog.mjs --gallery ../pptx-gallery # writes spec/catalogs + manifest\nnode scripts/sync-gallery-catalog.mjs --gallery ../pptx-gallery --report # per-kind counts\n```\n\nThe sync validates every published index and record against the schemas in `spec/schemas/`, checks the published\n`contentSha256`, drops `x-*` members, and rewrites only the files whose content changed. Writes require a clean gallery\ncheckout so the manifest commit identifies the actual catalog bytes. `--url https://www.pptx.gallery` reads the live\nsite for inspection and requires `--check` or `--report`. Every kind takes every published record; there is no\nper-id selection.\n\nA record rewrite the spec itself requires (the 0.15 font-scheme `languages` tags, for example) is edited under\n`spec/catalogs/<kind>/` and rehashed, and the gallery publishes the same records in the same train:\n\n```sh\nnode scripts/sync-gallery-catalog.mjs --rehash # index contentSha256, manifest records and contentSha256\nnode scripts/sync-gallery-catalog.mjs --rehash --match-gallery # also each kind\'s gallery block\n```\n\n### Checks\n\n- `pnpm check:spec` and `pnpm check:catalog` (both in `pnpm test`) verify offline that every kind\'s records still hash\n to the value in its index and the manifest.\n- `pnpm check:catalog-free` fails when a catalog module reappears in the import graph of core\'s root or of any subpath\n other than `/catalog`, or when their gzip size passes the recorded budget.\n- `sync-gallery-catalog.mjs --check` compares a local gallery checkout with the snapshot.\n\n## Reconciliation status\n\nThe per-kind divergence between the gallery and this snapshot is in\n[`programs/font-fidelity-everywhere/ff-37-catalog-divergence.md`](programs/font-fidelity-everywhere/ff-37-catalog-divergence.md).\n'
68
68
  },
69
69
  {
70
70
  "slug": "design-resolution",
71
71
  "file": "docs/design-resolution.md",
72
72
  "title": "Design Resolution",
73
- "markdown": '# Design Resolution\n\nHow an engine decides the effective design for any given slide. The schema spreads these rules across field descriptions; this page states them once, as an algorithm.\n\n## Precedence\n\nFor every design field independently, the most specific source wins:\n\n```\n wins +--------------------------------------------------------+\n ^ | 1. slide design slides[i].design.* |\n | +--------------------------------------------------------+\n | | 2. deck design design.* on the presentation root |\n | +--------------------------------------------------------+\n | | 3. resolved theme colorScheme, fontScheme, |\n | | background, dimensions from the |\n | | theme record |\n | +--------------------------------------------------------+\n loses | 4. engine defaults e.g. spec/reference/ |\n v | engine-defaults.json |\n +--------------------------------------------------------+\n```\n\n1. **Slide design** \u2014 `slides[].design.*`\n2. **Deck design** \u2014 `design.*` on the presentation root\n3. **Resolved theme** \u2014 defaults carried by the theme record (`colorScheme`, `fontScheme`, `background`, `dimensions`)\n4. **Engine defaults** \u2014 engine configuration such as [`spec/reference/engine-defaults.json`](../spec/reference/engine-defaults.json)\n\nResolution is **per field**, not per object. A slide that sets only `design.contentAlignment` inherits everything else from the deck design; a deck that sets only `design.colorScheme` keeps the theme\'s font scheme and background.\n\nTwo field-level rules complete the picture:\n\n- **Base-plus-overrides within one object.** Wherever a reference object carries an `id` (`Theme`, `ColorScheme`, `FontScheme`), the `id` resolves a catalog record as the base and sibling fields override the resolved record per key. The string shorthand (`"colorScheme": "cool-horizon"`) is equivalent to setting only `id`.\n\n ```\n "colorScheme": { "id": "cool-horizon", "accent1": "#0F4C81" }\n\n catalog record "cool-horizon" sibling fields on the object\n accent1: "#2874A6" <-- replaced -- accent1: "#0F4C81"\n accent2: "#1B4F72" <-- kept\n light1: "#FFFFFF" <-- kept\n |\n v\n effective scheme: accent1 from the override, everything else\n from the record\n ```\n- **Explicit suppression.** `watermark`, `header`, and `footer` accept `false` to switch off an inherited value \u2014 distinct from omitting the field, which inherits.\n\nCatalog lookups inside this chain follow the standard resolution order (inline `catalogs.<kind>.records[]` \u2192 `catalogs.<kind>.source` \u2192 default catalog); see [`how-opf-works.md`](./how-opf-works.md).\n\n## Worked example 1: color scheme through every level\n\n```json\n{\n "design": {\n "theme": "classic",\n "colorScheme": "forest-green"\n },\n "slides": [\n { "title": "Inherits the deck" },\n {\n "title": "Slide override",\n "design": {\n "colorScheme": { "id": "cool-horizon", "accent1": "#0F4C81" }\n }\n }\n ]\n}\n```\n\n- Slide 1: the `classic` theme record supplies its own default color scheme, but the deck design sets `colorScheme` explicitly, so `forest-green` wins (level 2 beats level 3). Fonts, background, and dimensions still come from `classic`.\n- Slide 2: slide design beats deck design (level 1 beats level 2). The `cool-horizon` record resolves as the base, then `accent1` is replaced by `#0F4C81`. All other `cool-horizon` slots survive.\n\nThere is no ambiguity between "override" and "reference": every scheme value *is* a reference, and any sibling fields on the same object are overrides applied after the reference resolves.\n\n## Worked example 2: backgrounds and suppression\n\n```json\n{\n "design": {\n "theme": "dark",\n "background": "light1",\n "footer": {\n "left": { "text": "Acme Corp" },\n "right": { "slideNumber": true }\n }\n },\n "slides": [\n { "title": "Light slide in a dark theme" },\n {\n "title": "Section divider",\n "design": {\n "background": {\n "type": "gradient",\n "gradient": {\n "angle": 90,\n "stops": [\n { "color": "#0B1B2B", "position": 0 },\n { "color": "#123A5F", "position": 1 }\n ]\n }\n },\n "footer": false\n }\n }\n ]\n}\n```\n\n- Slide 1: the deck-level `background: "light1"` overrides the `dark` theme\'s default background. `light1` is a theme slot \u2014 it resolves through the effective color scheme, which itself resolved through the chain above.\n- Slide 2: the gradient replaces the deck background for this slide only, and `footer: false` suppresses the inherited footer rather than inheriting or replacing it.\n\n## Worked example 3: font scheme models\n\n```json\n{\n "design": {\n "fontScheme": {\n "id": "aptos",\n "code": "JetBrains Mono"\n }\n }\n}\n```\n\nThe `aptos` record supplies the OOXML pair (`major`/`minor`). The `code` role is an OPF-specific addition with no OOXML slot, so it layers on top without disturbing the pair. When serializing to PowerPoint, engines write `major`/`minor` to `majorFont`/`minorFont` and map abstract roles (`heading`, `body`) onto those slots. `accent` has no slot; the `code` family is written directly on code runs. The same slot-versus-role split applies to color schemes: OOXML slots (`accent1`\u2013`accent6`, `dark1/2`, `light1/2`) round-trip directly, abstract roles (`primary`, `text`, `surface`, \u2026) are mapped onto slots by the engine.\n\n### Code font\n\nThe `code` role resolves per key like every other override:\n\n1. `code` on the effective `design.fontScheme` object;\n2. `code` on the resolved font-scheme record (the `consolas` and `courier-new` records carry `"Consolas"` and `"Courier New"`);\n3. otherwise **Roboto Mono**, the documented fallback that `@openpresentation/opf-render` bundles.\n\nThe heading and body families are never reused as the code fallback, so choosing `aptos` still gives Roboto Mono code unless the deck sets `code`. `resolveFontFamilies()` in `@openpresentation/opf/composition` applies these rules for all engines.\n\n### Engine default font scheme\n\nThe last-resort font scheme applies only when neither the slide, the deck nor the resolved theme names one. Every bundled theme names a font scheme (`minimal` uses `aptos`), and engines default the theme to `minimal`, so a document with no `design` gets `aptos` in every engine.\n\nEvery engine shares one last resort, `aptos`, so a custom theme without `fontScheme` is measured, paginated, previewed and exported in the same fonts. `@openpresentation/opf/composition` exports it as `DEFAULT_FONT_SCHEME` (`resolveScriptFonts()` uses it too), and [`engine-defaults.json`](../spec/reference/engine-defaults.json) records it as `fontScheme.pptx.latin`:\n\n| Engine | Last resort | Where |\n| --- | --- | --- |\n| Core pagination | `DEFAULT_FONT_SCHEME` (`aptos`) | `packages/javascript/src/pagination.ts` |\n| opf-render preview | `aptos` (`engineDefaults.fontScheme.pptx.latin`) | `src/svg.js` |\n| opf-editor composition and slide transfer | `aptos` | `src/font-defaults.js` |\n| opf-pptx export | `aptos` (`DEFAULTS.fontScheme`) | `src/index.js` |\n\n\n`fontScheme.google` (`roboto`, `noto-sans-sc`, `noto-sans`) is not read by any current engine. It is kept as the intended default for a future Google Slides exporter, whose output renders in Google-hosted fonts.\n\nAptos is not openly licensed, so no OPF package bundles it. Previews take the same path for the last resort as for any `aptos` deck:\n\n- **Estimated layout** (no `textMeasurement`): the SVG names `Aptos` and `Aptos Display`, and the default raster engine draws them with its bundled sans-serif fallback (Roboto).\n- **Measured layout** with the opf-render office font pack (the default for `prepareNodeFonts({pack: \'office\'})`): `Aptos` and `Aptos Display` resolve to Intos and Intos Display, metric-compatible replacements from the OPF font policy (0.000% mean width difference against Aptos 2.01), and the substitution report lists both. With only the base pack and `substitutionPolicy: "visual"` they fall back to the visual alternates Roboto and Carlito. The PPTX always names Aptos. See [font-fidelity.md](font-fidelity.md#font-policy-ff-31).\n- **Measured layout with only the base pack under the metric policy**: `font-unavailable` for Aptos, as for a document with no `design`. Supply licensed Aptos faces, allow visual substitution, or set a `fallbackFamily`.\n\nUntil FF-35 (font-fidelity-everywhere), core pagination, opf-render and opf-editor fell back to `roboto` while opf-pptx used `aptos`, so such a deck was measured in Roboto but exported with Aptos. None of the 126 bundled examples reaches the last resort: all 805 renderer golden rasters and all 126 exported PPTX files are byte-identical before and after the change. `packages/javascript/test/font-scheme-defaults.test.mjs` checks the shared default in core pagination, and each sibling repository has a parity test.\n\n### Unknown layout, theme and colour scheme\n\nThe same rule holds for the other catalog references, and no engine throws for them:\n\n- **Layout.** A slide with no `layout`, or with an id that no inline record, host catalog or default catalog defines, is composed with **no layout record**: title, subtitle, tag and content are arranged automatically, a slide with no body payload is a cover (the title block vertically centred) and the design hints apply. An engine must not substitute a different layout for an omitted one. An unknown id reports `unresolved-layout` (and `validate` a `catalog-reference` warning).\n- **Theme.** An unknown theme id uses the `minimal` record and reports `unresolved-theme`. An object reference with fields of its own is an inline record and reports nothing; an object with an `id` is laid over that id\'s record, and an object without an `id` over the default record (`minimal` for a theme, `cool-horizon` for a colour scheme), so a partial inline scheme keeps the slots it does not name.\n- **Colour scheme.** An unknown colour scheme id uses `cool-horizon` and reports `unresolved-color-scheme`; the theme\'s own colour scheme counts as the reference when the deck and slide name none.\n\nBoth diagnostics are `{ code, path, id, fallback, message }` with `path` where the id is written (`slides.N.layout`, `slides.N.design.theme`, `design.colorScheme`, ...). `resolveSlideContext` reports them, and it also returns `darkBackground` (the preview\'s decision: the one colour the background names, resolved through the colour scheme and the deck\'s `variables` so `var:brand`, a scheme slot or `primary`/`secondary`/`accent` work, has a WCAG relative luminance below 0.179; a gradient counts as light, a picture reads `light1`, a pattern its `backgroundColor`; no opacity is applied), which `composeSlide` uses to pick logo variants. Core pagination continues with the fallbacks and passes each diagnostic to `onDiagnostic`.\n\n### Unknown font scheme\n\nA font-scheme id that matches no inline or bundled record (`"fontScheme": "no-such-scheme"`, `{ "id": "no-such-scheme", ... }`, or a theme record that names one) is handled the same way in every engine. The document still validates, because an id may name a record from a catalog the engine has not loaded:\n\n1. The `DEFAULT_FONT_SCHEME` record (`aptos`) is the base. Sibling fields on an object reference still override it per key, so `{ "id": "no-such-scheme", "major": "Inter", "minor": "Inter" }` uses Inter, and a `code` role still applies.\n2. The engine reports one `unresolved-font-scheme` diagnostic: `{ code, path, id, fallback: "aptos", message }`. `path` is where the id is written: `slides.N.design.fontScheme`, `design.fontScheme`, or the `slides.N.design.theme` / `design.theme` reference whose record names it.\n3. An object without `id` is an inline scheme on the same base and reports nothing.\n\n`resolveFontSchemeReference(reference, lookup, path)` in `@openpresentation/opf/composition` implements this rule, and `resolveSlideContext(presentation, index, { fonts })` (package root) applies it, with the slide, deck and theme precedence above, for core pagination and the layout checks: it returns the `ComposeSlideOptions` for one slide (canvas, layout, families as `fontFamilies`, alignment, measurement) plus an `unresolved-font-scheme` diagnostic. Hosts that compose a slide call it instead of repeating the chain. `resolveFontFamilies()` also falls back to the default scheme\'s families (Aptos Display, Aptos) when a scheme names no heading or body family, instead of Roboto. Authoring-time `validate()` already warns about the unknown id (`opf/catalog-reference`).\n\n| Engine | Diagnostic channel | Reported |\n| --- | --- | --- |\n| Core pagination | `paginate(..., { onDiagnostic })` | once per path per call |\n| opf-render preview | `renderSvg` / `renderSvgDeck` `onDiagnostic` | once per path per rendered slide |\n| opf-editor | `session.composeSlide` / `paginateSlide` `onDiagnostic` option | once per call |\n| opf-pptx export | `toPptx(..., { onDiagnostic })` | once per path per export |\n\nBefore FF-35b, core pagination and opf-editor measured such decks in Roboto and opf-render threw `catalog-resolution-failed`. opf-pptx already exported Aptos, but reported nothing. None of the 126 bundled examples names an unknown font scheme. The 805 example SVGs, the 805 golden rasters and the 126 exported PPTX files are byte-identical before and after the change.\n\n### Sibling agreement checks\n\nopf-render, opf-editor and opf-pptx run the same unknown-scheme cases as core (`test/default-font-scheme.mjs`). Each package keeps a local copy of the default, and in opf-editor of the resolver. Their checks against core\'s `DEFAULT_FONT_SCHEME`, `resolveFontSchemeReference` and `paginate` run only when the installed core exports them. Those checks are skipped today: the siblings install the published `@openpresentation/opf` 0.11.0, which predates FF-35. They activate in either of two ways:\n\n- **Sibling CI:** after a core release that includes FF-35 and FF-35b is published, and each sibling\'s `@openpresentation/opf` dependency and lockfile move to it. After that release, the local copies can import core directly.\n- **Core ecosystem CI** (`.github/workflows/ecosystem-ci.yml`), which links this checkout\'s core into pinned sibling commits and runs their `npm test`: after those pins move to sibling commits that contain the FF-35 and FF-35b tests (the program\'s sibling pin bump).\n\nUntil then, the equality with core is established by running the sibling tests against a locally linked core.\n\n## Color references in content\n\nContent color fields (`TextRun.color`, styled table cell `style.fill` / `style.color`, table cell border `color`) accept references as well as literal hex, and those references resolve through the same chain above:\n\n```\n "color": "accent2" slot name -> effective color scheme slot\n "color": "text" role name -> role-to-slot mapping, then the slot\n "color": "var:risk" variable -> top-level variables map\n "color": "#B42318" literal -> used as-is (frozen at authoring time)\n```\n\n- **Slot names** (`accent1`\u2013`accent6`, `dark1`, `dark2`, `light1`, `light2`, `hyperlink`, `followedHyperlink`) read the named slot from the *effective* color scheme \u2014 the one produced by the slide \u2192 deck \u2192 theme \u2192 engine-default precedence at the top of this page. A slide-level `design.colorScheme` override therefore recolors that slide\'s named runs too.\n- **Role names** (`primary`, `secondary`, `accent`, `background`, `surface`, `text`, `textSecondary`) resolve through one shared definition, `resolveColorRoles()`, which the opf-render preview, the PPTX export and `validate` all call, so the same deck draws the same colors in each. A role defined on the effective scheme wins; otherwise it defaults from a slot:\n\n | Role | Default |\n | --- | --- |\n | `primary`, `secondary`, `accent` | `accent1`, `accent2`, `accent3` |\n | `background` | the slide\'s own resolved background when it is one color (solid, theme slot or pattern background color); else the scheme\'s `background` role, else `light1`. A gradient or picture background uses the scheme default. |\n | `surface` | `light2`, or `dark2` on a dark slide |\n | `text` | `dark1`, or `light1` on a dark slide. A `text` override applies on a light slide only, so a dark slide always keeps readable light text. |\n | `textSecondary` | `dark2`, or `light2` on a dark slide |\n\n A slide is dark when its background\'s WCAG relative luminance is under 0.179, the point where white and black text contrast equally.\n- **Links.** A link run (`link`) with no `color` of its own is drawn underlined in the scheme\'s `hyperlink` slot (the OOXML `hlink` color; Office blue `#0563C1` when the scheme sets none), in the preview and in the PPTX export (`a:schemeClr hlink` where the deck theme holds that color). Where that color has under 4.5:1 contrast against the slide background (the default Office blue on a dark slide), the slide\'s `text` color is used instead, as the slide tag does for a low-contrast primary color. A link run with its own `color` keeps it, and a `hyperlink` ColorRef always names the slot. `followedHyperlink` is written to the PPTX theme; a static preview cannot know which links were visited, so it draws every link in `hyperlink`.\n- **Variable references** (`var:<id>`) resolve against the document\'s top-level `variables` map, independent of the scheme. Variables are deck-scoped named colors \u2014 use them for values that have meaning (`var:risk`) or repeat across slides. An unknown id is a validation warning, never an error, and engines fall back to their default text color.\n\nThe styled table cell and border color fields enforce the reference forms at the schema level (a typo like `"acent2"` is a schema error there \u2014 neither hex, a known name, nor a `var:` reference). Run colors stay open strings so imported decks keep validating: an unrecognized run color is a validation warning, and renderers fall back to the theme text color \u2014 the same warn-don\'t-error posture unknown catalog ids get. Unknown `var:` ids are warnings everywhere.\n\n### Background colors are ColorRefs\n\nThe colors of a solid background (`SolidBackground.color`), of each gradient stop and of a pattern (`foregroundColor`, `backgroundColor`) take the same forms as the content fields above: a literal hex, a slot or role name, or a `var:<id>` reference. The schema keeps these fields as plain strings, so a reference validates; engines resolve it through `resolveColorRef()` against the effective color scheme and the deck `variables`, and a reference that resolves to nothing is drawn as the engine default (white for a background), as for any other unresolvable color. The default text color of the slide follows the resolved background. A PPTX export writes `a:schemeClr` where the deck theme holds the named slot or role exactly, and the resolved literal otherwise (FF-24 conventions).\n\n> **Decision, 2026-09-30 (agent decision, vetoable).** The spec coverage audit found that `SolidBackground.color` accepted `var:` and slot names (the field is a string) but both engines painted white, while [`content-item-design-overrides.md`](./content-item-design-overrides.md) already states that every color field must accept the ColorRef forms and never hex alone. Rather than tighten the schema (which would break documents that validate today), the engines resolve ColorRefs in backgrounds. The owner can veto this by restricting the three background color fields to `HexColor` in the schema and the engines to hex only.\n\n`@openpresentation/opf/composition` exports `resolveColorRef()` with the shared slot, role, variable, and hex rules above so renderers and exporters do not drift. Pass the effective color scheme, optional resolved role colors, the deck `variables` map, and a theme-text `fallback` for unrecognized references.\n\n## Script fonts and language\n\nOOXML gives each theme font (major and minor) three script slots: `latin`, East Asian (`ea`) and complex script (`cs`). The presentation `language` and the effective font scheme resolve to all three:\n\n```\n latin design font scheme heading/body (the chain above)\n eastAsian 1. design.fontScheme.eastAsian explicit slot\n complexScript 2. the scheme\'s own major/minor when languageFamily is ea / cs (or eastAsian /\n complexScript, the same values) and its\n languages list (language ids) is empty or\n names the language\n 3. the language\'s font scheme when the language\'s script uses the slot\n 4. the latin family otherwise\n```\n\n- A language record\'s `script` (ISO 15924) picks its slot. East Asian scripts (`Jpan`, `Hans`, `Hant`, `Kore`, ...) use `eastAsian`. Complex scripts (`Arab`, `Hebr`, `Deva`, `Thai`, ...) use `complexScript`. Latin, Cyrillic, Greek and other scripts use `latin`. `direction` defaults from the script (Arabic and Hebrew are right-to-left).\n- The language\'s `fontScheme` applies to PowerPoint output and `googleFontScheme` to Google Slides output. For Latin-script languages, the design font scheme always supplies the latin slot.\n- A language sets `lang`, the text direction and the script slots, and never the Latin scheme: only `design.fontScheme` (slide, then deck, then theme, then the shared default `aptos`) sets the latin fonts, and a language record\'s `fontScheme` is a default for its own script slot, not a deck font. The PPTX theme\'s `a:ea` and `a:cs` are written only for a slot a script font is selected for (the scheme\'s explicit slot or the language\'s script font) and stay empty otherwise, as in Office\'s own themes. See [Language contract](./programs/font-fidelity-everywhere/script-font-model.md#language-contract-ff-50-model-c) and [Theme slots](./programs/font-fidelity-everywhere/script-font-model.md#theme-slots-ff-49).\n- A Latin deck therefore repeats its heading/body family in `ea`/`cs`. A Japanese deck with `design.fontScheme: { "major": "Carlito", "minor": "Carlito" }` keeps the Latin family in `latin` and uses Meiryo (PowerPoint) or Noto Sans JP (Google Slides) in `ea`. `design.fontScheme.eastAsian` / `.complexScript` (`{ "major": ..., "minor": ... }`) name a script font explicitly, for example for CJK text inside a Latin deck.\n\n`@openpresentation/opf/composition` exports `resolveScriptFonts(document, { app, slideIndex })` (`app` is `"powerpoint"`, the default, or `"google-slides"`), and the package root exports `normalizeLanguageFamily(value)` (which reads a font scheme\'s `languageFamily`, `eastAsian` as `ea` and `complexScript` as `cs`). `resolveScriptFonts` returns the heading and body slots, the OOXML `lang` (a curated `ooxmlLang` culture tag such as `ja-JP` or `ms-MY`, or an authored region tag), the canonical `bcp47` tag, `script`, `direction`/`rtl`, and the per-script supplemental theme font. Renderers and exporters should use it rather than re-deriving slots. The model, the OOXML mapping and the open questions are in [`programs/font-fidelity-everywhere/script-font-model.md`](./programs/font-fidelity-everywhere/script-font-model.md). opf-render and opf-pptx implement it (FF-07, FF-19, FF-49).\n\n## Brand assets and layout hints\n\nThe 2026-09-30 spec coverage audit found that `design.logo`, `organization.logo`, `speaker.photo`, `design.contentDirection`, `design.chartPrimary`, `design.listBullet` and `fontScheme.accent` validated, edited and round-tripped but changed nothing in any engine. This section states what they do now. Every rule below is implemented once, in `composeSlide()` and `resolveLogo()` of `@openpresentation/opf`, and consumed by the renderer and the exporter; the decisions marked **(vetoable)** are agent decisions the owner can overturn.\n\n### Layout records: `placeholders` and `design`\n\nA layout record (`opf-layout/v1`) holds what it contains in `placeholders` and how it is meant to look in `design`. The content kinds are one vocabulary: `title`, `subtitle`, `tag`, `text`, `list`, `image`, `video`, `chart`, `table`, `code`, `metric`, `quote` and `timeline`.\n\n- `layoutContent(record)` (exported from `@openpresentation/opf`) derives `{ kind, count, heading: { title, subtitle, tag } }` from the placeholders. `kind` is the most frequent body placeholder kind (a tie goes to the first in placeholder order), or `title` when there is none; `count` is the number of body placeholders. Nothing derived is stored on the record.\n- `design` uses the keys and lowercase values of the deck\'s and a slide\'s `design`: `titleAlignment`, `contentAlignment`, `contentBox`, `contentDirection`, `chartPrimary`, `imageFill`, `listBullet` and `slideImage: { position }`. An absent key means the layout has no opinion. `pnpm check:spec` verifies that the layout schema\'s `DesignHints` and `Design` agree.\n- **One merge, per key, the slide winning:** the slide\'s `design`, then the deck\'s `design`, then the slide\'s layout record `design`, then the engine default. This holds for `titleAlignment`, `contentAlignment`, `contentBox`, `contentDirection`, `chartPrimary`, `imageFill` and `listBullet`, so a slide overrides exactly what its layout sets, by the same name, and a layout value nobody overrides is honored without copying it anywhere. `resolveDesignHints({ slide, layout, presentation, slideIndex })` (exported from `@openpresentation/opf/composition`) is the one place that computes it; it also reports which level supplied each key. `composeSlide()` returns the result as `SlideComposition.design`, and the renderer, the PPTX exporter, pagination and `validate` take `titleAlignment`, `contentAlignment`, `contentBox`, `imageFill` and `listBullet` from there instead of re-deriving them. A value the schema does not allow for its key is skipped, so the next level answers.\n- The layout\'s `composition.mode` still ranks above the design hint: `contentDirection` acts below it (see the decision below), and a layout\'s own `composition` columns and weights are overridden only by an effective `chartPrimary`.\n- `slideImage.position` is not a per-key merge: a layout whose record sets `slideImage` is what lets a deck-wide `design.slideImage` apply, and its `position` is the fallback when the slide\'s or the deck\'s value gives none.\n- Hosts no longer need to copy a layout\'s `design` into the deck or a slide. The pptx.gallery example builder and the editor\'s layout apply still do, which is harmless: a copied value is a deck or slide value and ranks above the record.\n- **Decision, 2026-10-06 (agent decision, vetoable).** On a cover, `tag` and `subtitle` follow `titleAlignment` unless the slide\'s own `design.contentAlignment` is set; a deck or layout `contentAlignment` does not split the heading group, exactly as a deck value did before.\n\n### Logo source and variant selection\n\n`resolveLogo(presentation, slide, { slot, onDark, slideIndex })` returns `{ source, path, variant, slot }` or `null`:\n\n```\n source 1. slides[i].design.logo\n 2. design.logo\n 3. the primary organization\'s logo role "primary", else the first\n organization; object or array\n variant a string or Asset object is the "default" variant\n a LogoSet picks by slot and tone (below)\n path design.logo, design.logo.light, organization.2.logo,\n slides.3.design.logo.icon, ...\n```\n\nAbsence inherits (there is no `false` for logos); a level that yields no usable asset falls through to the next. A LogoSet is searched in this order, same-tone variants first, neutral ones next, the opposite tone last:\n\n| Slot | On a dark background (`onDark: true`) | On a light background |\n| --- | --- | --- |\n| `lockup` | light, default, stackedLight, stacked, wordmarkLight, wordmark, iconLight, icon, then dark, stackedDark, wordmarkDark, iconDark | dark, default, stackedDark, stacked, wordmarkDark, wordmark, iconDark, icon, then light, stackedLight, wordmarkLight, iconLight |\n| `icon` | iconLight, icon, then the dark lockup chain | iconDark, icon, then the light lockup chain |\n| `stacked` | stackedLight, stacked, then the dark lockup chain | stackedDark, stacked, then the light lockup chain |\n\nHosts pass their own background luminance test as `composeSlide(..., { darkBackground })`; core never inspects colors.\n\n### Where the logo is drawn (vetoable)\n\n1. **Cover and section slides.** A slide with no body payload on a heading-only layout (`title`, `title-subtitle`, `section-divider`, any layout whose placeholders are all headings, or no layout: the same rule that centers covers) draws the `lockup` logo at the top-left of the free area, inside the slide padding and below any header furniture. `composeSlide` returns it as `geometry.logo` (`{ box, slot: \'lockup\', path, source, variant, anchor: \'left\' }`): `x = area.left + padding`, `y` at the image-safe heading top, `height = 56` reference pixels at a 720-pixel short edge, `width = min(4 * height, free width)`. Headings start one gap below the box and the cover-centering rule centers the tag/title/subtitle group in the remaining span; the logo itself does not move. Consumers fit the image inside the box preserving its aspect ratio, anchored left and vertically centered (SVG `preserveAspectRatio="xMinYMid meet"`; PPTX computes the fitted size from the raster dimensions and places it at `box.x`). Nothing is drawn when no logo resolves. **Content slides never get an automatic logo** (vetoable: it would move every content area).\n2. **Headers and footers.** `HeaderFooterItem.logo: true` generates an image furniture part with `field: \'logo\'`, `generated: true`, `image: resolved.source`, `path: <zone>.logo` and `sourcePath: resolved.path`, from the `icon` slot, in the same box as a zone `image`: as wide as the icon\'s proportions make it at the band height (a square when core cannot read them), flush with the zone\'s left edge, centered, or flush with its right edge like the zone\'s text. Fields in a zone stack in the order logo, image, text, organization, socials, section, slide number, date. Without a logo the engine reports `unresolved-content` at `<zone>.logo` ("Generated logo needs design.logo or a primary organization logo.").\n3. **Picture bullets.** See `listBullet` below.\n4. `organization.logo` is therefore drawn wherever the deck logo is: it is the fallback source, never a separate placement.\n\n> **Decision, 2026-09-30 (agent decision, vetoable).** 84 of the 126 bundled example decks carry a `design.logo` or an `organization.logo`, so 81 cover slides gain a logo and their heading group moves down. The placement (top-left, 56 px, lockup) and the content-slide exclusion are the reference-engine defaults; a layout-driven logo slot is a separate design.\n\n### `speaker.photo` is a built-in image variable (vetoable)\n\nNo reference engine draws a speaker photo by itself: the schema has no speaker slot on any slide and no slide-to-speaker link, and a speaker block on covers would be a separate design. The author places it explicitly with the built-in image variable (`"image": "var:speaker.photo"`, see [templates and variables](templates-and-variables.md#built-in-variables)); the field also round-trips through PPTX provenance.\n\n### `contentDirection`\n\nThe effective value is `slides[i].design.contentDirection`, then `design.contentDirection`. It sets the root arrangement mode: `vertical` is `column`, `horizontal` is `row`. Precedence for the root mode:\n\n```\n 1. composition.mode explicit: the slide\'s own, else the\n layout record\'s geometry contract\n 2. design.contentDirection slide design, then deck design\n 3. the layout record\'s design.contentDirection the layout\'s own direction\n 4. auto\n```\n\nPromoted regions (`left`, `top:left`, ...) keep their explicit geometry: `contentDirection` does not reinterpret them. Nested groups keep their own `composition`. The decision record keeps `reason: \'configured-mode\'`. Reserved placeholder slots still count when only the hint sets the mode, as they do for the layout record\'s `design.contentDirection`.\n\n> **Decision, 2026-09-30 (agent decision, vetoable).** A layout record\'s `composition.mode` ranks above the design hint. pptx.gallery copies a layout record\'s `design.contentDirection` into the design of its examples, so a hint that overrode the layout\'s `composition.mode` would flatten the layout\'s own grid by construction: `chart-2x` (`design.contentDirection: horizontal`, `mode: grid, columns: 2`) would compose its four blocks as one row under the `horizontal` it derives for itself, and the renderer\'s gallery-layout fixtures (57 layouts whose preview must differ from the default only in alignment) fail. The hint therefore ranks with the layout record\'s `design.contentDirection`, above it, and acts where no composition contract exists: slides without a layout and the bundled layouts without `composition.mode` (62 of 100). Under this rule no bundled example slide changes geometry for `contentDirection` (101 decks set it; all of their blocks slides use layouts that carry a mode). The alternative, ranking the hint above the layout mode, would change 125 single-payload example slides and break the gallery\'s own layouts.\n\n### `titleAlignment` and `contentAlignment` in right-to-left decks\n\n> **Decision, 2026-10-01 (RR-05, supervisor decision, vetoable).** Alignment is logical for right-to-left text. In a deck whose language is written right to left, `left` means the start edge and `right` the end edge of each paragraph: an Arabic or Hebrew paragraph with the default (or authored) `left` is drawn against the right edge, with its bullets, indents and table cells to match, while an English paragraph in the same deck keeps the left edge. `center` is unchanged and a left-to-right deck is unchanged. The effective value (slide design, then deck design, then `left`) is still what `item.alignment` reports; `placement.lines[i].alignment` and the exported `algn` carry the physical edge. A right-to-left deck that wants a paragraph at its end edge sets `right`. See [Layout direction](programs/font-fidelity-everywhere/script-font-model.md#layout-direction-rr-05).\n\n### `chartPrimary`\n\nThe effective value is `slides[i].design.chartPrimary`, then `design.chartPrimary`, then the layout record\'s `design.chartPrimary`. It applies to the root arrangement only when the slide has no promoted regions, no `composition.mode` of its own, and its root nodes contain at least one chart leaf and at least one node that is not a chart. The **first chart node is primary** and the other root nodes form one synthetic sub-grid:\n\n- `left` / `right`: the root is a two-track row with weights `[3, 2]` (chart first for `left`, last for `right`); the rest arrange in `auto` mode inside their track.\n- `top` / `bottom`: a two-track column with weights `[3, 2]` (chart first for `top`).\n- `none`, or any other value: no change, the existing automatic grid with equal weight.\n\nThe synthetic container has no OPF path, so it records no `groups`, `flows` or explanation entry; the root decision has `reason: \'chart-primary\'` and `selectedColumns` 2 (row) or 1 (column). Explicit root `columns` and `weights`, from the slide or the layout record, are ignored while it applies, and reserved placeholder slots are not applied. A chart inside a nested group, a chart-only root, or a single root `chart` payload leaves the arrangement unchanged.\n\n> **Decision, 2026-09-30 (agent decision, vetoable).** Unlike `contentDirection`, `chartPrimary` overrides the layout record\'s `composition.mode`, `columns` and `weights` (only the slide\'s own `composition.mode` blocks it). It is an author opt-in: no bundled layout record sets `design.chartPrimary`, so nothing derives it, while every bundled chart layout that mixes a chart with text carries a `composition.mode` (`chart-2x` grid, `data-visualization` row `[2, 1]`, ...). Ranking the layout mode above the hint would make the field inert on every bundled chart layout. 40 example slides (10 per side) change under this rule.\n\n### `listBullet` (vetoable)\n\n`character` (the default) draws the current glyph marker. `image` draws the deck\'s icon logo (`resolveLogo(..., { slot: \'icon\', onDark })`) as a picture bullet: every `items`/`bullets` item in `composeSlide` and every `listEntries[]` entry of its fit carry `bulletImage: { source, path }`. Marker geometry is unchanged, and each entry carries `bulletBox`, where the image draws: a square of side `marker.fontSize * PICTURE_BULLET_SCALE` (0.65, exported) whose bottom sits on the marker baseline (`marker.y`) with its left edge at `marker.x`. The scale is what desktop PowerPoint draws for an `a:buBlip` at `a:buSzPct 100000` (what the exporter writes), measured as a square 10, 15, 16, 20 and 31 px wide at font sizes of 16, 24, 25, 32 and 48 px (0.625 to 0.646; the heights run a pixel more from anti-aliasing) with its bottom on the text baseline, in Arial, Aptos, Georgia and Courier New alike, so it does not depend on the typeface; the text start and hanging indent are identical in both. The exporter sets no size, because PowerPoint sizes the bullet itself; consumers that draw it themselves (the preview) use `bulletBox`. Vetoable: the constant is a measurement, not a rule of the format. When `image` is set and no logo resolves, the glyph stays and the slide reports one `unresolved-content` diagnostic at `slides.N.design.listBullet` or `design.listBullet`, only when the slide has a list. The renderer draws an `<image>` per marker; the exporter writes native picture bullets (`a:buBlip`).\n\n### `fontScheme.accent`\n\n`resolveFontFamilies()` returns `accent` only when the effective scheme defines an `accent` role (a family string or a `Font` object). The slide `tag` (eyebrow) and the quote body use `fonts.accent ?? <current family>` (body for the tag, heading for the quote body); nothing else changes. The renderer loads and embeds it, the exporter writes it on those runs (`a:latin`) while the theme fonts stay major/minor, and import keeps restoring `design.fontScheme` from provenance. None of the bundled examples sets an accent font.\n\n## What is *not* part of this chain\n\nBeyond the color references above, content payloads carry no design controls in v1 \u2014 `position`, `fontSize` overrides at payload level, and the like were deliberately kept out while the content model stabilizes (see [`content-item-design-overrides.md`](./content-item-design-overrides.md); styled table cells and rich-text runs carry the only per-content styling, and their color fields take the reference forms above). The design system, plus layout hints (`titleAlignment`, `contentBox`, `chartPrimary`, ...) and dynamic composition, is the styling surface of an OPF document.\n'
73
+ "markdown": '# Design Resolution\n\nHow an engine decides the effective design for any given slide. The schema spreads these rules across field descriptions; this page states them once, as an algorithm.\n\n## Precedence\n\nFor every design field independently, the most specific source wins:\n\n```\n wins +--------------------------------------------------------+\n ^ | 1. slide design slides[i].design.* |\n | +--------------------------------------------------------+\n | | 2. deck design design.* on the presentation root |\n | +--------------------------------------------------------+\n | | 3. resolved theme colorScheme, fontScheme, |\n | | background, dimensions from the |\n | | theme record |\n | +--------------------------------------------------------+\n loses | 4. engine defaults e.g. spec/reference/ |\n v | engine-defaults.json |\n +--------------------------------------------------------+\n```\n\n1. **Slide design** \u2014 `slides[].design.*`\n2. **Deck design** \u2014 `design.*` on the presentation root\n3. **Resolved theme** \u2014 defaults carried by the theme record (`colorScheme`, `fontScheme`, `background`, `dimensions`)\n4. **Engine defaults** \u2014 [`spec/reference/engine-defaults.json`](../spec/reference/engine-defaults.json), compiled into core as `ENGINE_DEFAULT_THEME`, `ENGINE_DEFAULT_COLOR_SCHEME` and `ENGINE_DEFAULT_FONT_SCHEME`\n\nResolution is **per field**, not per object. A slide that sets only `design.contentAlignment` inherits everything else from the deck design; a deck that sets only `design.colorScheme` keeps the theme\'s font scheme and background.\n\nTwo field-level rules complete the picture:\n\n- **Base-plus-overrides within one object.** Wherever a reference object carries an `id` (`ColorScheme`, `FontScheme`, and the inline `Audience`, `Purpose` and `Tone`), the `id` resolves a catalog record as the base and sibling fields override the resolved record per key. `design.theme` is a reference string only. The string shorthand (`"colorScheme": "cool-horizon"`) is equivalent to setting only `id`.\n\n ```\n "colorScheme": { "id": "cool-horizon", "accent1": "#0F4C81" }\n\n catalog record "cool-horizon" sibling fields on the object\n accent1: "#2874A6" <-- replaced -- accent1: "#0F4C81"\n accent2: "#1B4F72" <-- kept\n light1: "#FFFFFF" <-- kept\n |\n v\n effective scheme: accent1 from the override, everything else\n from the record\n ```\n- **Explicit suppression.** `watermark`, `header`, and `footer` accept `false` to switch off an inherited value \u2014 distinct from omitting the field, which inherits.\n\nEvery reference inside this chain resolves the one way every content reference does: `catalogs.custom`, then the records embedded under `catalogs.default`, then the catalog the host registered for its source (`name:id` in `catalogs.<name>`, then the host catalog for its source); see [`how-opf-works.md`](./how-opf-works.md) and [the catalogs page](default-catalog.md).\n\n## Worked example 1: color scheme through every level\n\n```json\n{\n "design": {\n "theme": "classic",\n "colorScheme": "forest-green"\n },\n "slides": [\n { "title": "Inherits the deck" },\n {\n "title": "Slide override",\n "design": {\n "colorScheme": { "id": "cool-horizon", "accent1": "#0F4C81" }\n }\n }\n ]\n}\n```\n\n- Slide 1: the `classic` theme record supplies its own default color scheme, but the deck design sets `colorScheme` explicitly, so `forest-green` wins (level 2 beats level 3). Fonts, background, and dimensions still come from `classic`.\n- Slide 2: slide design beats deck design (level 1 beats level 2). The `cool-horizon` record resolves as the base, then `accent1` is replaced by `#0F4C81`. All other `cool-horizon` slots survive.\n\nThere is no ambiguity between "override" and "reference": every scheme value *is* a reference, and any sibling fields on the same object are overrides applied after the reference resolves.\n\n## Worked example 2: backgrounds and suppression\n\n```json\n{\n "design": {\n "theme": "dark",\n "background": "light1",\n "footer": {\n "left": { "text": "Acme Corp" },\n "right": { "slideNumber": true }\n }\n },\n "slides": [\n { "title": "Light slide in a dark theme" },\n {\n "title": "Section divider",\n "design": {\n "background": {\n "type": "gradient",\n "gradient": {\n "angle": 90,\n "stops": [\n { "color": "#0B1B2B", "position": 0 },\n { "color": "#123A5F", "position": 1 }\n ]\n }\n },\n "footer": false\n }\n }\n ]\n}\n```\n\n- Slide 1: the deck-level `background: "light1"` overrides the `dark` theme\'s default background. `light1` is a theme slot \u2014 it resolves through the effective color scheme, which itself resolved through the chain above.\n- Slide 2: the gradient replaces the deck background for this slide only, and `footer: false` suppresses the inherited footer rather than inheriting or replacing it.\n\n## Worked example 3: font scheme models\n\n```json\n{\n "design": {\n "fontScheme": {\n "id": "aptos",\n "code": "JetBrains Mono"\n }\n }\n}\n```\n\nThe `aptos` record supplies the OOXML pair (`major`/`minor`). The `code` role is an OPF-specific addition with no OOXML slot, so it layers on top without disturbing the pair. When serializing to PowerPoint, engines write `major`/`minor` to `majorFont`/`minorFont` and map abstract roles (`heading`, `body`) onto those slots. `accent` has no slot; the `code` family is written directly on code runs. The same slot-versus-role split applies to color schemes: OOXML slots (`accent1`\u2013`accent6`, `dark1/2`, `light1/2`) round-trip directly, abstract roles (`primary`, `text`, `surface`, \u2026) are mapped onto slots by the engine.\n\n### Code font\n\nThe `code` role resolves per key like every other override:\n\n1. `code` on the effective `design.fontScheme` object;\n2. `code` on the resolved font-scheme record (the `consolas` and `courier-new` records carry `"Consolas"` and `"Courier New"`);\n3. otherwise **Roboto Mono**, the documented fallback that `@openpresentation/opf-render` bundles.\n\nThe heading and body families are never reused as the code fallback, so choosing `aptos` still gives Roboto Mono code unless the deck sets `code`. `resolveFontFamilies()` in `@openpresentation/opf/composition` applies these rules for all engines.\n\n### Engine default font scheme\n\nThe last-resort font scheme applies only when neither the slide, the deck nor the resolved theme names one, or when the reference resolves nowhere. It is an engine default in code, not a catalog record: `ENGINE_DEFAULT_FONT_SCHEME` (`{ major: "Aptos Display", minor: "Aptos", languageFamily: "latin" }`, the drawing fields of the gallery\'s `aptos` scheme), recorded in [`engine-defaults.json`](../spec/reference/engine-defaults.json) and exported from the package root and `@openpresentation/opf/composition`. A document with no `design` uses the engine default theme (`ENGINE_DEFAULT_THEME`, which names no font scheme), so it gets Aptos in every engine without any catalog registered.\n\nEvery engine shares this one last resort, so a theme without `fontScheme` is measured, paginated, previewed and exported in the same fonts:\n\n| Engine | Last resort | Where |\n| --- | --- | --- |\n| Core pagination and validate | `ENGINE_DEFAULT_FONT_SCHEME` | `packages/javascript/src/design-records.ts` |\n| opf-render preview | core\'s `resolveSlideContext` | `src/svg.js` |\n| opf-editor composition and slide transfer | core\'s `resolveSlideContext` | `src/font-defaults.js` |\n| opf-pptx export | core\'s `resolveSlideContext` | `src/index.js` |\n\nAptos is not openly licensed, so no OPF package bundles it. Previews take the same path for the last resort as for any `aptos` deck:\n\n- **Estimated layout** (no `fonts`): the SVG names `Aptos` and `Aptos Display`, and the default raster engine draws them with its bundled sans-serif fallback (Roboto).\n- **Measured layout** with the opf-render office font pack (`loadFonts({pack: \'office\'})`): `Aptos` and `Aptos Display` resolve to Intos and Intos Display, metric-compatible replacements from the OPF font policy (0.000% mean width difference against Aptos 2.01), and the substitution report lists both. With only the base pack and `substitutionPolicy: "visual"` they fall back to the visual alternates Roboto and Carlito. The PPTX always names Aptos. See [font-fidelity.md](font-fidelity.md#font-policy-ff-31).\n- **Measured layout with only the base pack under the metric policy**: `font-unavailable` for Aptos, as for a document with no `design`. Supply licensed Aptos faces, allow visual substitution, or set a `fallbackFamily`.\n\n`packages/javascript/test/font-scheme-defaults.test.mjs` checks the shared default in core pagination, and each sibling repository has a parity test.\n\n### References that resolve nowhere\n\nA layout, theme, colour scheme or font scheme reference that neither the document embeds nor a registered catalog defines falls back, and no engine throws for it (unless the host asks for a strict export, `strictReferences`, which throws `OPFUnresolvedReferenceError`):\n\n- **Layout.** A slide with no `layout` is composed with **no layout record**, and that is not a finding: title, subtitle, tag and content are arranged automatically, a slide with no body payload is a cover (the title block vertically centred) and the design hints apply. A layout reference that resolves nowhere composes the same way. An engine never substitutes a different layout.\n- **Theme.** A theme reference that resolves nowhere uses the engine default theme (`ENGINE_DEFAULT_THEME`: the background and dimensions of the gallery\'s `minimal`).\n- **Colour scheme.** A colour-scheme reference that resolves nowhere uses `ENGINE_DEFAULT_COLOR_SCHEME` (the twelve slots of `cool-horizon`). The theme\'s own colour scheme counts as the reference when the deck and slide name none, and resolves in the theme\'s catalog group first. An object without an `id` is laid over the engine default, so a partial inline scheme keeps the slots it does not name.\n- **Font scheme.** A font-scheme reference that resolves nowhere uses `ENGINE_DEFAULT_FONT_SCHEME`. Sibling fields on an object reference still override it per key, so `{ "id": "no-such-scheme", "major": "Inter", "minor": "Inter" }` uses Inter, and a `code` role still applies. An object without `id` is an inline scheme on the same base and reports nothing.\n\nEach is one `unresolved-reference` diagnostic: `{ code, kind, reference, path, group, source?, fallback, message }`, with `path` where the reference is written (`slides.N.layout`, `slides.N.design.theme`, `design.colorScheme`, `design.fontScheme.id`, or `catalogs.<group>.themes.<id>.colorScheme` for a reference inside an embedded theme), `fallback` `automatic` for a layout and `engine-default` otherwise, and a message that names the reference and the catalog source it was looked for in. `validate` reports the same references as `opf/unresolved-reference` warnings.\n\n`resolveSlideContext(presentation, index, { catalogs, fonts })` (package root) applies these rules with the slide, deck and theme precedence above. It returns the `ComposeSlideOptions` for one slide (canvas, layout, families as `fontFamilies`, measurement), the diagnostics, and `darkBackground` (the preview\'s decision: the one colour the background names, resolved through the colour scheme and the deck\'s `variables` so `var:brand`, a scheme slot or `primary`/`secondary`/`accent` work, has a WCAG relative luminance below 0.179; a gradient counts as light, a picture reads `light1`, a pattern its `backgroundColor`; no opacity is applied), which `composeSlide` uses to pick logo variants. `resolveDesignRecords` and `resolveFontScheme` expose the same resolution for hosts that need only the records. Core pagination continues with the fallbacks and passes each diagnostic to `onDiagnostic`.\n\n| Engine | Diagnostic channel | Reported |\n| --- | --- | --- |\n| Core pagination | `paginate(..., { onDiagnostic })` | once per path per call |\n| opf-render preview | `renderSvg` / `renderSlideSvg` `onDiagnostic` | once per path per rendered slide |\n| opf-editor | `session.composeSlide` / `paginateSlide` `onDiagnostic` option | once per call |\n| opf-pptx export | `toPptx(..., { onDiagnostic })` | once per path per export |\n\nEvery engine passes the catalogs its host registered (the `catalogs` option) to core, so a record a host registers resolves the same in preview, editor and export.\n\n## Color references in content\n\nContent color fields (`TextRun.color`, styled table cell `style.fill` / `style.color`, table cell border `color`) accept references as well as literal hex, and those references resolve through the same chain above:\n\n```\n "color": "accent2" slot name -> effective color scheme slot\n "color": "text" role name -> role-to-slot mapping, then the slot\n "color": "var:risk" variable -> top-level variables map\n "color": "#B42318" literal -> used as-is (frozen at authoring time)\n```\n\n- **Slot names** (`accent1`\u2013`accent6`, `dark1`, `dark2`, `light1`, `light2`, `hyperlink`, `followedHyperlink`) read the named slot from the *effective* color scheme \u2014 the one produced by the slide \u2192 deck \u2192 theme \u2192 engine-default precedence at the top of this page. A slide-level `design.colorScheme` override therefore recolors that slide\'s named runs too.\n- **Role names** (`primary`, `secondary`, `accent`, `background`, `surface`, `text`, `textSecondary`) resolve through one shared definition, `resolveColorRoles()`, which the opf-render preview, the PPTX export and `validate` all call, so the same deck draws the same colors in each. A role defined on the effective scheme wins; otherwise it defaults from a slot:\n\n | Role | Default |\n | --- | --- |\n | `primary`, `secondary`, `accent` | `accent1`, `accent2`, `accent3` |\n | `background` | the slide\'s own resolved background when it is one color (solid, theme slot or pattern background color); else the scheme\'s `background` role, else `light1`. A gradient or picture background uses the scheme default. |\n | `surface` | `light2`, or `dark2` on a dark slide |\n | `text` | `dark1`, or `light1` on a dark slide. A `text` override applies on a light slide only, so a dark slide always keeps readable light text. |\n | `textSecondary` | `dark2`, or `light2` on a dark slide |\n\n A slide is dark when its background\'s WCAG relative luminance is under 0.179, the point where white and black text contrast equally.\n- **Links.** A link run (`link`) with no `color` of its own is drawn underlined in the scheme\'s `hyperlink` slot (the OOXML `hlink` color; Office blue `#0563C1` when the scheme sets none), in the preview and in the PPTX export (`a:schemeClr hlink` where the deck theme holds that color). Where that color has under 4.5:1 contrast against the slide background (the default Office blue on a dark slide), the slide\'s `text` color is used instead, as the slide tag does for a low-contrast primary color. A link run with its own `color` keeps it, and a `hyperlink` ColorRef always names the slot. `followedHyperlink` is written to the PPTX theme; a static preview cannot know which links were visited, so it draws every link in `hyperlink`.\n- **Variable references** (`var:<id>`) resolve against the document\'s top-level `variables` map, independent of the scheme. Variables are deck-scoped named colors \u2014 use them for values that have meaning (`var:risk`) or repeat across slides. An unknown id is a validation warning, never an error, and engines fall back to their default text color.\n\nThe styled table cell and border color fields enforce the reference forms at the schema level (a typo like `"acent2"` is a schema error there \u2014 neither hex, a known name, nor a `var:` reference). Run colors stay open strings so imported decks keep validating: an unrecognized run color is a validation warning, and renderers fall back to the theme text color \u2014 the same warn-don\'t-error posture unknown catalog ids get. Unknown `var:` ids are warnings everywhere.\n\n### Background colors are ColorRefs\n\nThe colors of a solid background (`SolidBackground.color`), of each gradient stop and of a pattern (`foregroundColor`, `backgroundColor`) take the same forms as the content fields above: a literal hex, a slot or role name, or a `var:<id>` reference. The schema keeps these fields as plain strings, so a reference validates; engines resolve it through `resolveColorRef()` against the effective color scheme and the deck `variables`, and a reference that resolves to nothing is drawn as the engine default (white for a background), as for any other unresolvable color. The default text color of the slide follows the resolved background. A PPTX export writes `a:schemeClr` where the deck theme holds the named slot or role exactly, and the resolved literal otherwise (FF-24 conventions).\n\n> **Decision, 2026-09-30 (agent decision, vetoable).** The spec coverage audit found that `SolidBackground.color` accepted `var:` and slot names (the field is a string) but both engines painted white, while [`content-item-design-overrides.md`](./content-item-design-overrides.md) already states that every color field must accept the ColorRef forms and never hex alone. Rather than tighten the schema (which would break documents that validate today), the engines resolve ColorRefs in backgrounds. The owner can veto this by restricting the three background color fields to `HexColor` in the schema and the engines to hex only.\n\n`@openpresentation/opf/composition` exports `resolveColorRef()` with the shared slot, role, variable, and hex rules above so renderers and exporters do not drift. Pass the effective color scheme, optional resolved role colors, the deck `variables` map, and a theme-text `fallback` for unrecognized references.\n\n## Script fonts and language\n\nOOXML gives each theme font (major and minor) three script slots: `latin`, East Asian (`ea`) and complex script (`cs`). The presentation `language` and the effective font scheme resolve to all three:\n\n```\n latin design font scheme heading/body (the chain above)\n eastAsian 1. design.fontScheme.eastAsian explicit slot\n complexScript 2. the scheme\'s own major/minor when languageFamily is ea / cs (or eastAsian /\n complexScript, the same values) and its\n languages list (language ids) is empty or\n names the language\n 3. the language\'s font scheme when the language\'s script uses the slot\n 4. the latin family otherwise\n```\n\n- A language record\'s `script` (ISO 15924) picks its slot. East Asian scripts (`Jpan`, `Hans`, `Hant`, `Kore`, ...) use `eastAsian`. Complex scripts (`Arab`, `Hebr`, `Deva`, `Thai`, ...) use `complexScript`. Latin, Cyrillic, Greek and other scripts use `latin`. `direction` defaults from the script (Arabic and Hebrew are right-to-left).\n- The language\'s `fontScheme` applies to PowerPoint output and `googleFontScheme` to Google Slides output. For Latin-script languages, the design font scheme always supplies the latin slot.\n- A language sets `lang`, the text direction and the script slots, and never the Latin scheme: only `design.fontScheme` (slide, then deck, then theme, then the shared default `aptos`) sets the latin fonts, and a language record\'s `fontScheme` is a default for its own script slot, not a deck font. The PPTX theme\'s `a:ea` and `a:cs` are written only for a slot a script font is selected for (the scheme\'s explicit slot or the language\'s script font) and stay empty otherwise, as in Office\'s own themes. See [Language contract](./programs/font-fidelity-everywhere/script-font-model.md#language-contract-ff-50-model-c) and [Theme slots](./programs/font-fidelity-everywhere/script-font-model.md#theme-slots-ff-49).\n- A Latin deck therefore repeats its heading/body family in `ea`/`cs`. A Japanese deck with `design.fontScheme: { "major": "Carlito", "minor": "Carlito" }` keeps the Latin family in `latin` and uses Meiryo (PowerPoint) or Noto Sans JP (Google Slides) in `ea`. `design.fontScheme.eastAsian` / `.complexScript` (`{ "major": ..., "minor": ... }`) name a script font explicitly, for example for CJK text inside a Latin deck.\n\n`@openpresentation/opf/composition` exports `resolveScriptFonts(document, { app, slideIndex })` (`app` is `"powerpoint"`, the default, or `"google-slides"`), and the package root exports `normalizeLanguageFamily(value)` (which reads a font scheme\'s `languageFamily`, `eastAsian` as `ea` and `complexScript` as `cs`). `resolveScriptFonts` returns the heading and body slots, the OOXML `lang` (a curated `ooxmlLang` culture tag such as `ja-JP` or `ms-MY`, or an authored region tag), the canonical `bcp47` tag, `script`, `direction`/`rtl`, and the per-script supplemental theme font. Renderers and exporters should use it rather than re-deriving slots. The model, the OOXML mapping and the open questions are in [`programs/font-fidelity-everywhere/script-font-model.md`](./programs/font-fidelity-everywhere/script-font-model.md). opf-render and opf-pptx implement it (FF-07, FF-19, FF-49).\n\n## Brand assets and layout hints\n\nThe 2026-09-30 spec coverage audit found that `design.logo`, `organization.logo`, `speaker.photo`, `design.contentDirection`, `design.chartPrimary`, `design.listBullet` and `fontScheme.accent` validated, edited and round-tripped but changed nothing in any engine. This section states what they do now. Every rule below is implemented once, in `composeSlide()` and `resolveLogo()` of `@openpresentation/opf`, and consumed by the renderer and the exporter; the decisions marked **(vetoable)** are agent decisions the owner can overturn.\n\n### Layout records: `placeholders` and `design`\n\nA layout record (`opf-layout/v1`) holds what it contains in `placeholders` and how it is meant to look in `design`. The content kinds are one vocabulary: `title`, `subtitle`, `tag`, `text`, `list`, `image`, `video`, `chart`, `table`, `code`, `metric`, `quote` and `timeline`.\n\n- `layoutContent(record)` (exported from `@openpresentation/opf`) derives `{ kind, count, heading: { title, subtitle, tag } }` from the placeholders. `kind` is the most frequent body placeholder kind (a tie goes to the first in placeholder order), or `title` when there is none; `count` is the number of body placeholders. Nothing derived is stored on the record.\n- `design` uses the keys and lowercase values of the deck\'s and a slide\'s `design`: `titleAlignment`, `contentAlignment`, `contentBox`, `contentDirection`, `chartPrimary`, `imageFit` and `listBullet`. An absent key means the layout has no opinion. `pnpm check:spec` verifies that the layout schema\'s `DesignHints` and `Design` agree.\n- **One merge, per key, the slide winning:** the slide\'s `design`, then the deck\'s `design`, then the slide\'s layout record `design`, then the engine default. This holds for `titleAlignment`, `contentAlignment`, `contentBox`, `contentDirection`, `chartPrimary`, `imageFit` and `listBullet`, so a slide overrides exactly what its layout sets, by the same name, and a layout value nobody overrides is honored without copying it anywhere. `resolveDesignHints({ slide, layout, presentation, slideIndex })` (exported from `@openpresentation/opf/composition`) is the one place that computes it; it also reports which level supplied each key. `composeSlide()` returns the result as `SlideComposition.design`, and the renderer, the PPTX exporter, pagination and `validate` take `titleAlignment`, `contentAlignment`, `contentBox` and `listBullet` from there instead of re-deriving them; every image item carries its resolved fit (the block\'s own, else the effective `imageFit`, else `cover`) in `item.image.fit`. A value the schema does not allow for its key is skipped, so the next level answers.\n- The layout\'s `composition.mode` still ranks above the design hint: `contentDirection` acts below it (see the decision below), and a layout\'s own `composition` columns and weights are overridden only by an effective `chartPrimary`.\n- A layout\'s `image` placeholder may carry `placement` (FA-22): the slide\'s n-th top-level image bleeds to that edge unless its block sets its own placement. A layout does not opt into a picture: any layout sits on any background ([images](image-treatments.md)).\n- Hosts no longer need to copy a layout\'s `design` into the deck or a slide. The pptx.gallery example builder and the editor\'s layout apply still do, which is harmless: a copied value is a deck or slide value and ranks above the record.\n- **Decision, 2026-10-06 (agent decision, vetoable).** On a cover, `tag` and `subtitle` follow `titleAlignment` unless the slide\'s own `design.contentAlignment` is set; a deck or layout `contentAlignment` does not split the heading group, exactly as a deck value did before.\n\n### Logo source and variant selection\n\n`resolveLogo(presentation, slide, { slot, onDark, slideIndex })` returns `{ source, path, variant, slot }` or `null`:\n\n```\n source 1. slides[i].design.logo\n 2. design.logo\n 3. the primary organization\'s logo role "primary", else the first\n organization; object or array\n variant a string or Asset object is the "default" variant\n a LogoSet picks by slot and tone (below)\n path design.logo, design.logo.light, organization.2.logo,\n slides.3.design.logo.icon, ...\n```\n\nAbsence inherits (there is no `false` for logos); a level that yields no usable asset falls through to the next. A LogoSet is searched in this order, same-tone variants first, neutral ones next, the opposite tone last:\n\n| Slot | On a dark background (`onDark: true`) | On a light background |\n| --- | --- | --- |\n| `lockup` | light, default, stackedLight, stacked, wordmarkLight, wordmark, iconLight, icon, then dark, stackedDark, wordmarkDark, iconDark | dark, default, stackedDark, stacked, wordmarkDark, wordmark, iconDark, icon, then light, stackedLight, wordmarkLight, iconLight |\n| `icon` | iconLight, icon, then the dark lockup chain | iconDark, icon, then the light lockup chain |\n| `stacked` | stackedLight, stacked, then the dark lockup chain | stackedDark, stacked, then the light lockup chain |\n\nHosts pass their own background luminance test as `composeSlide(..., { darkBackground })`; core never inspects colors.\n\n### Where the logo is drawn (vetoable)\n\n1. **Cover and section slides.** A slide with no body payload on a heading-only layout (`title`, `title-subtitle`, `section-divider`, any layout whose placeholders are all headings or placed images, or no layout: the same rule that centers covers) draws the `lockup` logo at the top-left of the free area, inside the slide padding and below any header furniture. `composeSlide` returns it as `geometry.logo` (`{ box, slot: \'lockup\', path, source, variant, anchor: \'left\' }`): `x = area.left + padding`, `y` at the image-safe heading top, `height = 56` reference pixels at a 720-pixel short edge, `width = min(4 * height, free width)`. Headings start one gap below the box and the cover-centering rule centers the tag/title/subtitle group in the remaining span; the logo itself does not move. Consumers fit the image inside the box preserving its aspect ratio, anchored left and vertically centered (SVG `preserveAspectRatio="xMinYMid meet"`; PPTX computes the fitted size from the raster dimensions and places it at `box.x`). Nothing is drawn when no logo resolves. **Content slides never get an automatic logo** (vetoable: it would move every content area).\n2. **Headers and footers.** `HeaderFooterItem.logo: true` generates an image furniture part with `field: \'logo\'`, `generated: true`, `image: resolved.source`, `path: <zone>.logo` and `sourcePath: resolved.path`, from the `icon` slot, in the same box as a zone `image`: as wide as the icon\'s proportions make it at the band height (a square when core cannot read them), flush with the zone\'s left edge, centered, or flush with its right edge like the zone\'s text. Fields in a zone stack in the order logo, image, text, organization, socials, section, slide number, date. Without a logo the engine reports `unresolved-content` at `<zone>.logo` ("Generated logo needs design.logo or a primary organization logo.").\n3. **Picture bullets.** See `listBullet` below.\n4. `organization.logo` is therefore drawn wherever the deck logo is: it is the fallback source, never a separate placement.\n\n> **Decision, 2026-09-30 (agent decision, vetoable).** 84 of the 126 bundled example decks carry a `design.logo` or an `organization.logo`, so 81 cover slides gain a logo and their heading group moves down. The placement (top-left, 56 px, lockup) and the content-slide exclusion are the reference-engine defaults; a layout-driven logo slot is a separate design.\n\n### `speaker.photo` is a built-in image variable (vetoable)\n\nNo reference engine draws a speaker photo by itself: the schema has no speaker slot on any slide and no slide-to-speaker link, and a speaker block on covers would be a separate design. The author places it explicitly with the built-in image variable (`"image": "var:speaker.photo"`, see [templates and variables](templates-and-variables.md#built-in-variables)); the field also round-trips through PPTX provenance.\n\n### `contentDirection`\n\nThe effective value is `slides[i].design.contentDirection`, then `design.contentDirection`. It sets the root arrangement mode: `vertical` is `column`, `horizontal` is `row`. Precedence for the root mode:\n\n```\n 1. composition.mode explicit: the slide\'s own, else the\n layout record\'s geometry contract\n 2. design.contentDirection slide design, then deck design\n 3. the layout record\'s design.contentDirection the layout\'s own direction\n 4. auto\n```\n\nPromoted regions (`left`, `top:left`, ...) keep their explicit geometry: `contentDirection` does not reinterpret them. Nested groups keep their own `composition`. The decision record keeps `reason: \'configured-mode\'`. Reserved placeholder slots still count when only the hint sets the mode, as they do for the layout record\'s `design.contentDirection`.\n\n> **Decision, 2026-09-30 (agent decision, vetoable).** A layout record\'s `composition.mode` ranks above the design hint. pptx.gallery copies a layout record\'s `design.contentDirection` into the design of its examples, so a hint that overrode the layout\'s `composition.mode` would flatten the layout\'s own grid by construction: `chart-2x` (`design.contentDirection: horizontal`, `mode: grid, columns: 2`) would compose its four blocks as one row under the `horizontal` it derives for itself, and the renderer\'s gallery-layout fixtures (57 layouts whose preview must differ from the default only in alignment) fail. The hint therefore ranks with the layout record\'s `design.contentDirection`, above it, and acts where no composition contract exists: slides without a layout and the bundled layouts without `composition.mode` (62 of 100). Under this rule no bundled example slide changes geometry for `contentDirection` (101 decks set it; all of their blocks slides use layouts that carry a mode). The alternative, ranking the hint above the layout mode, would change 125 single-payload example slides and break the gallery\'s own layouts.\n\n### `titleAlignment` and `contentAlignment` in right-to-left decks\n\n> **Decision, 2026-10-01 (RR-05, supervisor decision, vetoable).** Alignment is logical for right-to-left text. In a deck whose language is written right to left, `left` means the start edge and `right` the end edge of each paragraph: an Arabic or Hebrew paragraph with the default (or authored) `left` is drawn against the right edge, with its bullets, indents and table cells to match, while an English paragraph in the same deck keeps the left edge. `center` is unchanged and a left-to-right deck is unchanged. The effective value (slide design, then deck design, then `left`) is still what `item.alignment` reports; `placement.lines[i].alignment` and the exported `algn` carry the physical edge. A right-to-left deck that wants a paragraph at its end edge sets `right`. See [Layout direction](programs/font-fidelity-everywhere/script-font-model.md#layout-direction-rr-05).\n\n### `chartPrimary`\n\nThe effective value is `slides[i].design.chartPrimary`, then `design.chartPrimary`, then the layout record\'s `design.chartPrimary`. It applies to the root arrangement only when the slide has no promoted regions, no `composition.mode` of its own, and its root nodes contain at least one chart leaf and at least one node that is not a chart. The **first chart node is primary** and the other root nodes form one synthetic sub-grid:\n\n- `left` / `right`: the root is a two-track row with weights `[3, 2]` (chart first for `left`, last for `right`); the rest arrange in `auto` mode inside their track.\n- `top` / `bottom`: a two-track column with weights `[3, 2]` (chart first for `top`).\n- `none`, or any other value: no change, the existing automatic grid with equal weight.\n\nThe synthetic container has no OPF path, so it records no `groups`, `flows` or explanation entry; the root decision has `reason: \'chart-primary\'` and `selectedColumns` 2 (row) or 1 (column). Explicit root `columns` and `weights`, from the slide or the layout record, are ignored while it applies, and reserved placeholder slots are not applied. A chart inside a nested group, a chart-only root, or a single root `chart` payload leaves the arrangement unchanged.\n\n> **Decision, 2026-09-30 (agent decision, vetoable).** Unlike `contentDirection`, `chartPrimary` overrides the layout record\'s `composition.mode`, `columns` and `weights` (only the slide\'s own `composition.mode` blocks it). It is an author opt-in: no bundled layout record sets `design.chartPrimary`, so nothing derives it, while every bundled chart layout that mixes a chart with text carries a `composition.mode` (`chart-2x` grid, `data-visualization` row `[2, 1]`, ...). Ranking the layout mode above the hint would make the field inert on every bundled chart layout. 40 example slides (10 per side) change under this rule.\n\n### `listBullet` (vetoable)\n\n`character` (the default) draws the current glyph marker. `image` draws the deck\'s icon logo (`resolveLogo(..., { slot: \'icon\', onDark })`) as a picture bullet: every `items`/`bullets` item in `composeSlide` and every `listEntries[]` entry of its fit carry `bulletImage: { source, path }`. Marker geometry is unchanged, and each entry carries `bulletBox`, where the image draws: a square of side `marker.fontSize * PICTURE_BULLET_SCALE` (0.65, exported) whose bottom sits on the marker baseline (`marker.y`) with its left edge at `marker.x`. The scale is what desktop PowerPoint draws for an `a:buBlip` at `a:buSzPct 100000` (what the exporter writes), measured as a square 10, 15, 16, 20 and 31 px wide at font sizes of 16, 24, 25, 32 and 48 px (0.625 to 0.646; the heights run a pixel more from anti-aliasing) with its bottom on the text baseline, in Arial, Aptos, Georgia and Courier New alike, so it does not depend on the typeface; the text start and hanging indent are identical in both. The exporter sets no size, because PowerPoint sizes the bullet itself; consumers that draw it themselves (the preview) use `bulletBox`. Vetoable: the constant is a measurement, not a rule of the format. When `image` is set and no logo resolves, the glyph stays and the slide reports one `unresolved-content` diagnostic at `slides.N.design.listBullet` or `design.listBullet`, only when the slide has a list. The renderer draws an `<image>` per marker; the exporter writes native picture bullets (`a:buBlip`).\n\n### `fontScheme.accent`\n\n`resolveFontFamilies()` returns `accent` only when the effective scheme defines an `accent` role (a family string or a `Font` object). The slide `tag` (eyebrow) and the quote body use `fonts.accent ?? <current family>` (body for the tag, heading for the quote body); nothing else changes. The renderer loads and embeds it, the exporter writes it on those runs (`a:latin`) while the theme fonts stay major/minor, and import keeps restoring `design.fontScheme` from provenance. None of the bundled examples sets an accent font.\n\n## What is *not* part of this chain\n\nBeyond the color references above, content payloads carry no design controls in v1 \u2014 `position`, `fontSize` overrides at payload level, and the like were deliberately kept out while the content model stabilizes (see [`content-item-design-overrides.md`](./content-item-design-overrides.md); styled table cells and rich-text runs carry the only per-content styling, and their color fields take the reference forms above). The design system, plus layout hints (`titleAlignment`, `contentBox`, `chartPrimary`, ...) and dynamic composition, is the styling surface of an OPF document.\n'
74
74
  },
75
75
  {
76
76
  "slug": "dynamic-composition",
77
77
  "file": "docs/dynamic-composition.md",
78
78
  "title": "Dynamic composition",
79
- "markdown": "# Dynamic composition\n\nOPF keeps authoring intent in JSON. Use `blocks` when content can reflow; use promoted regions when relative placement is meaningful. `composition` on a slide overrides fields in the resolved layout's `composition`. Existing documents remain valid.\n\nThe current published Node 24 train is core 0.13.0, renderer 0.13.1, PPTX 0.13.2, editor 0.12.1 and CLI 0.11.0. Use the exact pins in [release-plan.json](../release-plan.json); the [compatibility matrix](compatibility-matrix.md) separates package support from native Office and font gates. Older version references below identify when individual contracts were introduced.\n\n```json\n{\n \"name\": \"Decision brief\",\n \"slides\": [{\n \"title\": \"Make the main idea clear\",\n \"composition\": { \"mode\": \"row\", \"weights\": [2, 1], \"overflow\": \"error\" },\n \"blocks\": [\n { \"text\": \"The evidence and recommendation receive twice the width.\" },\n { \"text\": \"The supporting detail receives the remaining width.\" }\n ]\n }]\n}\n```\n\n`auto` evaluates candidate grids using text fit and cell proportions. `columns` limits its candidates. `grid` uses `columns` if given, otherwise a grid based on the canvas shape. `row` uses one row; `column` uses one column. Items retain source order. Weights size columns except in column mode, where they size rows. Missing weights are 1; unused weights have no effect. A partially filled final row retains its grid tracks.\n\n`gap` defaults to 1/30 and `padding` to 0.08, both fractions of the canvas's shorter edge. Large gaps are reduced when necessary to keep cells positive. `minFontSize` defaults to 16 reference pixels at a 720-pixel short edge. The reference coordinate system uses 96 pixels per inch. Explicit inch dimensions override presets independently for each axis.\n\nComposed font sizes lie on PowerPoint's 0.01 pt grid (RR-16). PowerPoint stores a run size (`sz`) in hundredths of a point and a point is 4/3 reference pixels, so every size a fit accepts is a whole multiple of 1/75 px (`FONT_SIZE_GRID_PER_PX`), and the preview draws exactly the size the export writes. Fitting evaluates each trial size on the grid before breaking lines and placing text: trial sizes stay anchored to the unsnapped request (no drift) and round down (`snapFontSizeDown`), so a size that fit before snapping still fits; a readability floor rounds up (`snapFontSizeUp`), so `minFontSize` is never undercut; and a result that reports no overflow was measured at the size it carries. The rule covers plain, rich, list (marker, description and picture-bullet side), table, quote, code, metric, timeline, furniture and heading text, including each rich run and script. `wrapText` measures at exactly the size it is given.\n\nHeadings reserve space according to their wrapped text. Title and subtitle share the padded width of the free area, and a missing tag or subtitle leaves no gap.\n\nCover slides vertically center the combined tag/title/subtitle group in the free heading area. A cover is a slide with no body payload (no root content field including `image`, no `blocks`, no promoted regions; empty payloads such as `blocks: []`, `text: \"\"`, empty lists and regions with nothing in them count as no body; whitespace-only text is still body) on a heading-only layout: layout id `title` or `title-subtitle`, or a layout whose placeholders are all headings, or a slide with no layout at all. The free area is the slide minus the image-safe band reserved by a `left`, `right`, `top` or `bottom` slide image, header and footer furniture, and the usual padding; a `background` image reserves nothing. A wrapped heading makes the group taller and the group recenters. A group that already fills the free area is not moved. Accepted line and outline origins move with the boxes. Explicit heading `alignment` positions ink inside the box and never changes the vertical position. A root `image` that is drawn as the slide image still counts as body, so image slides keep the top-aligned content origin. Content slides are not affected: headings stay at the top and the body follows them. This is a reference-engine default, not a schema field.\n\nContent that exceeds the number of preset placeholders reflows together; it is not drawn over already-bound content. Promoted regions keep the 3\xD73 vocabulary, including standalone `top`, `middle`, and `bottom`. They ignore flow direction and track weights. They are composed in visual reading order (rows from top to bottom, then along the row; `visualReadingOrder`), not in key order, so the composed item order, the preview's draw order, the PPTX shape order and the reading order of assistive technology agree. The order is computed from the logical region cells, so a right-to-left deck reads the same logical order.\n\nEvery shared `design` key resolves with one merge, per key: the slide's `design`, then the deck's `design`, then the slide's layout record `design`, then the engine default (`resolveDesignHints`; the result is `SlideComposition.design`). Two design hints shape the root arrangement. `design.contentDirection` (slide design, deck design, then the layout record's own) sets the root mode, `vertical` as `column` and `horizontal` as `row`, when neither the slide nor its layout record sets a `composition.mode`; it ranks with the layout record's `design.contentDirection`, above automatic selection, regions and nested groups are untouched, and the decision keeps `reason: 'configured-mode'`. `design.chartPrimary` (slide, deck, then the layout record's `design.chartPrimary`) applies when the slide sets no `composition.mode` of its own and the root nodes mix at least one chart with other content: the first chart becomes a primary track and the other nodes form one synthetic sub-grid arranged in `auto` mode, a two-track row for `left`/`right` or column for `top`/`bottom`, weighted 3:2 in favor of the chart, with explicit root `columns`/`weights` ignored; the synthetic container has no path and records no group, flow or decision, and the root decision reports `reason: 'chart-primary'`. On cover slides `geometry.logo` places the deck logo above the centered heading group. [Design resolution](design-resolution.md#brand-assets-and-layout-hints) states the precedence, the logo variant selection, picture bullets and the accent font, with the vetoable decisions.\n\n## Shared headers and footers\n\nPublished core 0.11.0 exposes `layoutFurniture(slide, options)` and `geometry.furniture`, separate from body `items`. Composition identifies the available-space policy as `grid-score-v9`. Raw callers pass the presentation as `options.presentation`, resolved dimensions/fonts and the same measurement provider used by preview. `slideIndex` identifies source paths; optional `slideNumber` is the one-based displayed number.\n\nThe core resolver honors whole local header/footer overrides, including `false` and empty objects. Each zone retains its image and all configured text fields in source-aware parts. Literal text and dates preserve whitespace and empty strings. Organization and section values point to their metadata source; page numbers use the actual output sequence. A missing organization/section, or `date: true` without a host-supplied current date, produces `unresolved-content`; the implementation never consults a clock or invents source text.\n\nSlide numbers and dates carry formats. `slideNumberFormat` is a template such as `\"A-{current}\"` or `\"{current} / {total}\"`: `{current}` is the displayed number and `{total}` is the displayed slide count (`options.slideCount`, else `presentation.slides.length`; whole-deck pagination iterates to the final page count). `dateFormat` is an LDML-style pattern (`yyyy`, `yy`, `MMMM`, `MMM`, `MM`, `M`, `dd`, `d`, `EEEE`, `EEE`, quoted literals) with fixed English month and weekday names. A string `date` with `dateFormat` must be an ISO `YYYY-MM-DD` date and renders as fixed, generated text tied to that source value; without `dateFormat` a date string stays literal and editable. `date: true` is the current date: hosts pass today's ISO date as `options.date` (composition, pagination, renderer and exporter), and the default pattern is `M/d/yyyy`. Text parts expose `fields` (half-open UTF-16 ranges of each `{current}` number and of a whole current date), so exporters can write native live fields while `{total}` and fixed dates stay fixed text. `formatFurnitureDate()` and `formatSlideNumber()` are exported for hosts. All configured fields in one zone stack, in the order logo, image, text, organization, socials, section, slide number, date (`logo: true` is the deck's icon logo as a generated image part; see [design resolution](design-resolution.md#brand-assets-and-layout-hints)); put a date and a slide number in different zones to keep one line each. Hiding furniture on a title slide is the slide-level `design.header: false` / `design.footer: false` override.\n\n`speaker: true` generates one `speaker` part from the first speaker: `\"Ada Lovelace, CTO\"`, or the name alone when there is no title. Its `sourcePath` is `speaker.name` (a single speaker) or `speaker.0.name`. A deck with no named speaker produces `unresolved-content`. In a zone it stacks after `organization` and before `socials`: logo, image, text, organization, speaker, socials, section, slide number, date. PPTX export writes it as static text and import restores the flag from furniture provenance.\n\n`socials: true` (core 0.11.1 and later) generates one `socials` part from the primary organization's `organization.socials`. The part has one source line per platform, in key order, and a parallel `links` array (`platform`, `text`, `href`, `resolved`, `sourcePath`). `resolveSocialProfile(platform, value, records, owner)` formats each value without network access. A handle loses its `handlePrefix` and is substituted into `companyUrlPattern`, then `profileUrlPattern`, then `baseUrl/{handle}`; the result is shown as that URL without `https://`. A URL value passes through unchanged except that `https://` is dropped from the display. With no matching record, the value is shown raw with no link, which is the Socials engine fallback. Records come from inline `catalogs.socialPlatforms.records` first and then from host-supplied `options.socialPlatforms`. Composition never loads the bundled catalog itself; `resolveSlideContext` (which `paginate`, opf-render and opf-pptx use) passes it. A missing organization, or one with no non-empty socials, produces `unresolved-content`. Speaker socials, platform icons, brand colors and slide-size presets are not rendered.\n\n`furniture-flow-v2` gives each left/center/right zone 26% of the canvas width. Parts stack within a zone; the tallest zone sets the natural band height. Text uses at least the selected readability floor, with complete accepted source lines and optional measured outline placement. Header and footer bands reserve room before heading and body allocation. Irreducible text, conflicting bands or a heading displaced beyond the remaining space produce diagnostics; strict composition rejects them. An image or `logo: true` part (`type: 'image'`) aligns like the zone's text: its `box` is as wide as the image's own proportions make it at the band height (a 36 px band at 1280 px wide), at most the zone, flush with the zone's left edge in the left zone, centered in the center zone and flush with its right edge in the right zone, at the same vertical position as before; consumers fit the image inside `box`. Core reads the proportions without fetching, from an embedded PNG, JPEG, GIF, WebP or SVG data URI or an `asset:` reference to one, decoding only a bounded prefix of the payload (64 KiB, 1 MiB for a JPEG whose frame header sits behind large metadata) and memoizing per source, so a multi-megabyte logo costs nothing per slide; a JPEG whose header is past 1 MiB, an SVG whose root tag is past 64 KiB, a path or a URL is unreadable is placed in a square box (vetoable), so a wide image behind a path or URL is drawn small and flush to its zone edge until it is embedded. No-furniture body geometry remains unchanged.\n\nPagination repeats these fields without putting them among body slices. An optional `page.repeatedMappings` records repeated heading/furniture and metadata paths while the existing `page.mappings` retains its body-fragment contract. Whole-deck pagination evaluates final output numbers, including preceding continuation pages, and rejects unresolved repeated content atomically. Renderer and editor reuse the accepted parts; literal text/date fields, including empty values, support direct canvas editing and undo. Generated labels remain tied to metadata.\n\nPublished PPTX (0.9.1 and later, current 0.13.2) draws the accepted editable text boxes and fitted images and records furniture provenance in tagged slide shapes. Reimport uses current native text and images; damaged or ambiguous provenance retains visible content with diagnostics. The [fresh installed-package evidence](evidence/shipped-train-20260921/installed/acceptance-summary.json) includes deterministic export, current-content reimport controls and offline canvas editing/undo. Native PowerPoint acceptance, font compatibility and full visual review remain separate gates; the published PPTX (through 0.11.7) is not native `p:hf` Header/Footer support. On [opf-pptx main](https://github.com/OpenPresentation/opf-pptx) (RR-11, unreleased) the first footer text, date and slide number that fit one accepted line become real PowerPoint `ftr`, `dt` and `sldNum` placeholders at exactly this geometry, with master/layout placeholders and `p:hf` flags (see [native header and footer](https://github.com/OpenPresentation/opf-pptx/blob/main/docs/native-header-footer.md)); header parts, organization, section, socials, images and multi-line text stay tagged shapes because PowerPoint has no object for them. No core geometry or schema changed. Bounds/readability checks do not certify whole-slide design quality: long labels can wrap heavily in portrait zones, and outline agreement does not establish native font identity.\n\n## Footnote areas and caption bands\n\nRR-34 (core after 0.11.4) adds two reserved regions that exist only for decks that use the fields. A slide whose runs carry `cite` or `footnote` markers gets `geometry.footnotes` (`footnote-area-v1`): a rule and the slide's notes in number order, directly above the footer band (or the bottom padding) with the content area's left edge and width, in the body family at the furniture size; the content area shrinks by exactly the area's height plus half the slide gap, and nothing above it moves. The area takes at most 35% of the span between the heading top and the footer band; a note that does not fit reports `text-overflow` at its source path (`references.N` or the run's path), which pagination treats like any other fit overflow. `slideCitations(slide, slideIndex, presentation)` is the numbering `composeSlide` uses; pass the same `presentation` and `slideIndex` to every consumer. An `image`, `chart`, `table` or `video` payload with a `caption` reserves a caption band inside its region (`item.caption`; `item.box` becomes the media box), below or above the media, at most 35% of the region; automatic grid selection scores the leaf on its media box. See [footnotes, citations and captions](footnotes-citations-captions.md).\n\n## Slide-level images\n\nCore 0.11.1 and later resolve `design.slideImage` into `geometry.slideImage`, beside body `items`. It applies to a slide in three cases:\n\n- The slide sets its own `design.slideImage`.\n- The deck sets `design.slideImage` and the slide's layout record sets `design.slideImage`.\n- The deck sets `design.slideImage` and the slide's root `image` is the same source, as in the pptx.gallery image-treatment snippets.\n\nOther slides ignore a deck-level value, so existing decks keep their geometry: 81 bundled example decks set a deck-level slide image and none of them changes. When the value is the asset shorthand rather than a `{ position }` object, the layout record's `design.slideImage.position` supplies the position, and `background` is the fallback.\n\n`background` gives the image the whole slide, and headings and content compose unchanged over it. `left`, `right`, `top` and `bottom` give the image half the slide, edge to edge, and headings and content compose in the other half with the usual padding. Header and footer bands keep their full-width placement. The frame uses `design.imageFill`, with `crop` as the default: `crop` covers the frame from the center and `fit` shows the whole image centered inside it. Without `design.imageFill`, the content-image default stays `fit`.\n\nThe slide's root `image` becomes the slide image, not a second content item, in two cases: the treatment object omits `src`, or `src` is the same source as the root image. A root image with a different source stays content. The result reports `path` (the configuring design value), `sourcePath` (where the drawn asset lives), `region`, `box` and `replacesContent`. Coordinated opf-render draws the frame beneath content. Coordinated opf-pptx exports one native `p:pic` at the same frame, with crop and fit written as `a:srcRect`. A tagged picture that has not been edited imports back as the slide's `design.slideImage`. The treatment vocabulary is covered in [image treatments](image-treatments.md): size, inset, aspect ratio, preset masks, line, opacity, grayscale or duotone, and overlay. That page also gives the support status of each pptx.gallery treatment.\n\n## Nested groups\n\n### Shared content cards\n\nShared content cards are published in core 0.10.0 and later, including current core 0.13.0. For `design.contentBox: true`, each body leaf carries a `frameBox` at its outer allocation and a `box` padded inward by 12 reference pixels at a 720-pixel short edge, capped at one quarter of the frame's width or height. Scoring, accepted payload measurement, strict overflow and pagination all use that rounded interior. Headings remain unframed, nested groups keep their original padding, and explicit outer regions/track weights remain authoritative. Automatic candidates may change because their available content space changes.\n\nEvery composed item carries its resolved horizontal text `alignment` (`left`, `center` or `right`). The title uses `titleAlignment`; every other item, including subtitle, tag, body text, lists, tables and metrics, uses `contentAlignment`. The value is the effective `design` key: the slide's design, then the deck's design (or the host option), then the layout record's `design`, then `left`, so a layout that sets `contentAlignment: center` centers its content with no deck or slide value. The title never inherits `contentAlignment`. A cover (a slide with no body payload on a heading-only layout) has no content region, so its tag and subtitle join the title's alignment: they follow `titleAlignment`, and only a `contentAlignment` set on the slide's own design keeps them apart (a deck or layout value does not). Accepted outline placement and metric internals use the same value. The renderer and the PPTX exporter anchor preview and native text to `item.alignment`, so both engines place a layout's text the same way.\n\nRaw composition callers pass their resolved deck flag as `composeSlide(slide, {contentBox: effectiveDesign.contentBox, ...options})`; a slide's explicit `design.contentBox: false` overrides it. Coordinated renderer, editor and whole-presentation pagination resolve this option for their callers. Consumers draw at `frameBox` and use the accepted `box` and payload internals without another inset. Core 0.9.0 predates this behavior. Content cards do not make the incomplete chart/timeline density models complete or certify native raster fidelity.\n\nA block or promoted region can contain its own `blocks` and `composition`. The optional discriminator is `\"type\": \"group\"`. A group has at least one child and cannot mix children with leaf fields such as `text` or `image`.\n\n```json\n{\n \"composition\": { \"mode\": \"row\", \"weights\": [2, 1] },\n \"blocks\": [\n {\n \"composition\": { \"mode\": \"column\", \"padding\": 0.02 },\n \"blocks\": [{ \"text\": \"Recommendation\" }, { \"text\": \"Supporting evidence\" }]\n },\n { \"text\": \"Context\" }\n ]\n}\n```\n\nThe parent allocates a box to each group, then the group arranges its children inside that box. Group padding defaults to zero; padding and gap use the group's shorter edge. Only `minFontSize` and `overflow` inherit. A strict ancestor cannot be weakened by a child's `overflow: \"warn\"`. Font sizes remain relative to the canvas, not the group. Groups can nest up to 32 levels; cycles and deeper nesting fail with an explicit error.\n\nAutomatic grid scoring inspects descendant text using each descendant's explicit arrangement or geometric automatic seed. After selecting the parent's grid, it optimizes each child's automatic grid. This deterministic, bounded search avoids exponential combinations; it does not claim a globally optimal packing.\n\n`result.items` contains every leaf with its full source path and effective composition. `result.groups` contains group paths, outer bounds, and content bounds. The editor's `setGroupComposition(path, value)` validates and records undo/redo just like slide composition edits.\n\n## Inspecting and repairing layout\n\n```js\nimport { composeSlide } from '@openpresentation/opf/composition';\nconst result = composeSlide(deck.slides[0], { width: 1280, height: 720, layout: resolvedLayout });\nconsole.log(result.items); // Source paths, content, geometry, and text estimates\nconsole.log(result.diagnostics); // Path-specific text-overflow and small-cell messages\n```\n\nThis pure function expects a validated slide. The caller resolves catalog records and passes the canvas size, `fontFamilies` and `textMeasurement`; `resolveSlideContext(deck, index, { fonts })` from the package root does the catalog resolution for one slide of a deck and returns those options. The rendering and export packages perform those steps at their boundaries. No network, DOM, system font, or AI dependency is required.\n\n### Explain automatic selection (core 0.8.0 and later)\n\nPass `explain: true` to return `result.explanation`. This opt-in API requires core 0.8.0; it is absent from core 0.7.0. Enabling explanations adds no measurement calls and does not change geometry, source content, reading order, weights or selected arrangements within the same engine version.\n\n```js\nconst result = composeSlide(slide, {...resolvedOptions, explain: true});\nfor (const decision of result.explanation.decisions) {\n console.log(decision.path, decision.reason, decision.selectedColumns);\n console.table(decision.candidates);\n}\nconsole.log(result.explanation.textMeasurement);\nconsole.log(result.explanation.unmeasuredPayloads);\n```\n\n`resolvedOptions` supplies the same dimensions, layout, fonts and optional width provider as the preview. Core 0.8.0 identifies its explanation as `grid-score-v2`; core 0.9.0 advances to `grid-score-v3` to include complete code metadata/body measurements. Current core 0.13.0 reports `grid-score-v9`. These versions record containers in parent-before-child order. `lowest-score` reports the candidates actually tried; `configured-mode` respects resolved row/column/grid intent and returns no invented candidates. `promoted-regions` leaves region placement fixed and has no selected column count. Empty slides have no decisions. Automatic search tries one through `min(slotCount, columns ?? 6)` columns, in ascending order; ties retain the first candidate. Reserved placeholders count as slots. The schema caps an explicit candidate limit at twelve columns.\n\nEach candidate has `columns`, `rows`, `score` and additive `penalties`:\n\n| Penalty | Rule |\n| --- | --- |\n| `cellProportions` | Sum of `abs(log(cellAspect / 1.6))` for descendant leaves |\n| `fontReduction` | Reduction from 25 reference pixels for text-like leaves; current quote/code/metric/timeline layouts sum requested-minus-fitted sizes across their parts, divided by canvas scale |\n| `textOverflow` | 1,000 per overflowing text-like leaf or complete quote/code/metric/timeline payload, regardless of the number of internal failure reasons |\n| `tableOverflow` | 1,000 per table whose shared cell layout overflows |\n| `smallCells` | 100 per leaf narrower than 100 or shorter than 60 reference pixels |\n| `emptySlots` | 2 per unused position in the candidate grid's final row |\n\nScores are preference costs, not quality percentages or guarantees. Floating-point summation can make the component total differ slightly from `score`. Parent scoring uses descendant explicit arrangements or geometric automatic seeds; child automatic grids are optimized only after selecting the parent. Candidate scores therefore describe the bounded search, not a full assessment of the final optimized subtree. Heading fit remains in ordinary diagnostics, outside body-grid scoring. A strict-fit rejection exposes the explanation on `OPFCompositionError` when requested.\n\n`textMeasurement` is `estimated` without a provider and `provided` with one. A provided width function does not establish font provenance, glyph coverage, shaping or native raster fidelity. Text, rich text, lists, quotes, table cells and code in core 0.9 participate in the fit model. Current core 0.11.0 also measures metric and timeline parts; its `unmeasuredPayloads` identifies images, video and charts whose complete internal layout is not assessed. Core 0.8 reports code as incomplete; core 0.9 measures its filename/language/body and insets. Media aspect ratios, chart labels and complete timeline visual density still require inspection. A zero score or empty diagnostics is not proof that those payloads fit.\n\nExplanations expose the search for inspection and do not silently paginate or rewrite a document. Published editor 0.8.0 provides guarded track resizing, block moves, creation/removal and explicit pagination with preview/undo. A general automatic repair loop, automatic weight allocation, complete payload-internal measurement, CLI explanations and a canvas **Auto arrange** preview/undo operation remain open work; the **Arrange** controls below are explicit human adjustments.\n\nWith `overflow: \"warn\"` (default), the result retains all text and returns diagnostics. SVG emits all lines and marks overflowing groups with `data-opf-overflow=\"true\"`; text may extend beyond its box or canvas. Consumers can collect diagnostics using `onDiagnostic`. With `overflow: \"error\"`, the layout rejects content that does not fit. Shorten the affected content, give it more space, or explicitly split it into another slide. Use the explicit pagination transform below to produce additional editable slides.\n\nThe editor exposes `editor.composeSlide(index)` and `editor.setComposition(index, value)`. The latter validates the change, records JSON Patch history, and supports undo/redo.\n\n## Pagination\n\n```js\nimport { paginateSlide, paginate } from '@openpresentation/opf/pagination';\nconst { presentation, pages } = paginate(deck);\n// Review, save, render, or export `presentation`; pages maps output fragments to source paths.\nconst single = paginateSlide(deck.slides[0], { width: 1280, height: 720, minFontSize: 24 });\n```\n\nPagination is an authoring operation. It produces ordinary OPF slides; previews and PPTX export consume those exact pages. It preserves the input, body order, nested groups, promoted regions, rich-text formatting, and source text characters. Plain text and rich runs split at grapheme boundaries, preferring sentence/paragraph breaks and then word breaks. Lists split between items, tables between rows with column labels repeated, and code splits without rewriting its source. Indivisible payloads remain intact. Existing track weights continue to apply to positions on each resulting page.\n\nThe default readability target is 24 reference pixels. Core 0.8.0 returns slides that persist that floor in `composition.minFontSize`, including an already-fitting one-page result; existing higher minima and strict overflow policies remain intact. Quotes can raise their nominal body/footer sizes to the floor. Current core 0.11.0 enforces the selected floor in shared plain/rich/list/table fitting, including painted rich fragments, while retaining authored style metadata. Unsupported fits report overflow rather than silently capping output below the floor. The [readability-floor checkpoint](plans/readability-floor.md) records the earlier candidate and its bounded-search tradeoffs. Pagination relies on the shared engine's estimates; it is not a guarantee that every host font renders identically. Headings repeat unchanged, speaker notes remain on the first page, and continuation IDs avoid existing deck IDs. `pages[].mappings` records full source/output paths and half-open text or item ranges. Text offsets use UTF-16, so source strings can be reconstructed exactly. Quote bodies split at grapheme boundaries and repeat complete attribution/source fields on each page. An irreducible footer rejects the whole operation, including a quote with an empty body after earlier content.\n\nIf a heading, individual list item, table row, or other atomic payload cannot fit on an otherwise empty page, `OPFPaginationError` returns actionable diagnostics. There is no partial output. `maxSlides` defaults to 100, and a layout-evaluation limit bounds work on pathological input. Specialized chart and timeline internals still require visual inspection; their complete density models remain outstanding.\n\nThe editor's `editor.paginateSlide(index)` is one validated transaction with undo/redo. It returns `{change, pagination}`. Editor 0.5.0 commits a one-page readability-policy change too; repeating the operation after the policy is recorded returns `change: null`. The playground includes an overflowing draft and **Split overflow** action. The CLI writes a new file and refuses to overwrite an existing one:\n\n```sh\nopf paginate input.opf.json output.opf.json\n```\n\n## Right-to-left decks\n\nWhen the presentation `language` is written right to left (Arabic, Hebrew, Syriac, Thaana and the other right-to-left scripts) or the host passes `direction: 'rtl'`, `composeSlide` mirrors the composition and reports each paragraph's direction; `SlideComposition.direction` is `'rtl'`. The first column and the `left` region are drawn at the right, banded slide images, cover logos and header/footer zones swap sides, lists put their markers at the right, tables run right to left, and `TextFit.directions` gives the direction of the paragraph each line belongs to. Alignment is logical: the authored `left` is the start edge, drawn at the right edge of a right-to-left paragraph (`physicalAlignment`). A left-to-right deck composes exactly as before. The rules and the PPTX mapping are in [Layout direction](programs/font-fidelity-everywhere/script-font-model.md#layout-direction-rr-05).\n\n## Fidelity boundary\n\nThe shared engine provides identical body and heading geometry to SVG and editable PPTX export. Text measurements default to deterministic estimates. For actual font advances, use the shared provider described in [measured fonts](font-fidelity.md). Complex scripts, fallback fonts, PowerPoint text rendering, rich text, charts, tables, and images still need visual verification. Dynamic composition is not a guarantee of pixel-identical PowerPoint output. List density includes rich runs, descriptions and nesting via `fitList`, with the same hanging indents used in preview and export. Only text-like payloads currently receive content-density estimates; small-cell diagnostics also cover non-text content.\n\nSVG embeds raster data URI images locally. Remote and file images require a host resolver that supplies a raster data URI; otherwise they appear as placeholders. `strictAssets` rejects unresolved images. The runtime never fetches them.\n\nSee [the complete example](../examples/technical/dynamic-composition.opf.json) and [local ecosystem verification](ecosystem-development.md).\n\n\n## Preview polish shared by preview and export (RR-07)\n\nThree accepted spec fields used to be drawn plain; core now owns the shared tables so the SVG preview and the PPTX export draw them identically. Consumers import them from `@openpresentation/opf`; none changes geometry, and an older core simply leaves the preview and the export plain, without a new diagnostic.\n\n- **Code syntax colours.** `tokenizeCode(source, language)` returns sorted, non-overlapping token ranges (`keyword`, `string`, `number`, `comment`, `function`, `type`, `property`) over the exact `code.source`; `codeLineRuns(tokens, start, end, source?)` clips them to one accepted source line, so concatenating the runs returns the line unchanged (a tab is always its own plain run). The scanner is deterministic and dependency-free (no network, clock or locale). `code.language` resolves through aliases (`ts`, `tsx`, `py`, `sh`, `yml`, `c++`, `terraform`, and so on) to `javascript`, `typescript`, `python`, `rust`, `go`, `bash`, `json`, `yaml`, `toml`, `ini`, `html`/`xml`, `css`, `sql`, `java`, `csharp`, `kotlin`, `swift`, `ruby`, `php`, `c`/`cpp`, `hcl` and `dockerfile` (`resolveCodeLanguage`, `CODE_HIGHLIGHT_LANGUAGES`); any other language, a missing one, or source over 200,000 characters stays plain. `codeSyntaxPaletteForScheme(colorScheme)` gives one colour per kind from the deck theme (keyword = primary, string = accent, number = secondary; comment, function, type and property are fixed slate, blue, cyan and pink, rotated away from a theme colour they would collide with), each lightened until it is at least 4.5:1 on the #111827 code panel. The renderer nests coloured tspans inside each accepted segment tspan; PPTX writes the same colours as native runs in the same one-text-box-per-line shapes, so editing and import are unchanged. The language is not inferred from `code.filename`. `code.highlight` (1-based source lines and inclusive ranges) adds a band behind marked lines and dims the rest: `codeHighlightLines` resolves it against the source, `codeLineNumbers` numbers each displayed line (a wrapped line repeats its number), `codeHighlightBands` returns the runs of displayed lines to draw behind, and `codeHighlightColors(colorScheme)` gives the band and the marked/dimmed palettes, all at least 4.5:1 on what they sit on. The SVG band is one `rect` per run behind the line text; PPTX writes one native rectangle per run (`OPF code N highlight M`) after the panel and before the line text boxes, so the one-text-box-per-line shapes and the code provenance tags are unchanged.\n- **Metric trend.** A `metric.trend` (`up`, `down`, `flat`) keeps its word as the visible, editable text and gains one arrow: `metricTrendMark(layout, {background})` derives the arrow from the accepted trend line (square, 0.72 of the font size, on the baseline, 0.3 of the font size from the word; after the word for left alignment, before it for centre and right, and omitted if it would leave the field). The arrow is the DrawingML `upArrow`, `downArrow` or `rightArrow` preset at default adjustments (`metricTrendPoints` is the outline the preview draws), coloured by the metric's `sentiment`: positive green, negative red, neutral a neutral colour, kept at 4.5:1 or more against the slide background (`metricTrendColor`); the delta and trend text take the same colour. `layoutMetric` passes `sentiment` through as `MetricLayout.sentiment` (omitted when the metric has none) and `metricTrendMark` reads it, so the arrow direction always follows `trend` and only the colour follows `sentiment`. Absent, the sentiment is the conventional reading of the trend (up positive, down negative, flat neutral), which is the colour drawn before `sentiment` existed. A `sentiment` without a `trend` draws no arrow and colours nothing. The arrow carries the alternative text \"Trend: up\" (`aria-label` in SVG, `descr` in PPTX); the word and the arrow carry the direction, the colour the verdict.\n- **Pattern fills.** `PATTERN_PRESETS` lists the 54 ECMA-376 ST_PresetPatternVal names and `patternBitmap(preset)` / `patternRuns(preset)` give each as an 8 x 8 one-bit tile, one pixel per 1/96 inch, anchored at the slide's top-left, foreground where a bit is set (`diagStripe` still resolves to `wdUpDiag`). ECMA-376 names the presets but does not define their pixels: the tiles are measured from desktop PowerPoint (Office 365, Windows, 2026-10-01) by exporting each preset as a full-slide background to a 1280 x 720 PNG and voting every pixel into its (x mod 8, y mod 8) cell with opf-render `scripts/derive-pattern-bitmaps.mjs`. Every tile was uniform across all repeats (confidence 1.00) at one image pixel per pattern pixel, so a pattern pixel is one 1/96 inch unit, and the phase is the slide's top-left corner. The tool also reports any tile that later differs. PPTX already wrote every preset as native `a:pattFill`.\n\n## Metric internals (published coordinated packages)\n\nPublished core 0.11.0 exports `layoutMetric(value, box, options)` from `@openpresentation/opf/composition` (the package root no longer exports engine names). It accepts a finite number, string, or `{value, unit?, label?, description?, delta?, trend?, sentiment?}`. Renderer 0.9.0, editor 0.8.0 and PPTX 0.9.1 consume its shared geometry for composition, atomic pagination, preview, editing and export. Core 0.9.0 predates this API. The [primitive checkpoint](plans/shared-metric-layout.md) and [source integration checkpoint](plans/shared-metric-integration.md) record its development; current pins and remaining native/font gates are in the [compatibility matrix](compatibility-matrix.md).\n\nPass the allocated reference-pixel `box`, resolved heading/body `fontFamilies`, `textMeasurement`, canvas `scale`, effective `minFontSize`, source `path` and optional `overflow: 'error'`. `metric-flow-v1` returns separate value/unit/label/description/delta/trend parts in that order. Numeric zero is visible; scalar values keep the scalar path. Every provided field retains its original string or number in `sources[].value`. Source ranges address `String(value)` using UTF-16 offsets; the original spelling of a numeric JSON token is not available. No locale formatting, trend icon, case conversion or separator is invented. Empty optional strings retain source mappings with `visible: false`; the required empty value keeps a targetable blank line.\n\nThe allocator tries an adjacent value/unit baseline when both fit one line and the unit uses at most 35% of the cell width; otherwise it stacks the fields. Metadata has an eight-reference-pixel gap, with twelve pixels after the primary row. Related fields stay together rather than being separated by a percentage of the cell height. The value starts at up to 76 reference pixels (28% of cell height); label/unit/delta start at 23, description at 20 and trend at 18. Every requested size is raised to the chosen floor, scaled once. Natural metadata height gets space before reducing type. At most 48 arrangements are evaluated, each with at most 77 value-size trials; identical inputs and a deterministic measurement provider select the fitting candidate with least summed font reduction, preferring the first candidate on ties.\n\nEach part exposes requested/resolved styles and the same source-preserving line/segment representation used by code (`CodeTextFit`), measured with proportional heading/body fonts. CR/LF/CRLF, tabs, whitespace and grapheme boundaries remain exact. Consumers must reuse accepted line and segment positions, font sizes and styles rather than independently re-fit or normalize text. This API reports `provided` measurement when a provider is passed, without claiming that its glyph coverage or shaping is complete. Unsupported glyphs propagate the provider's error with the field path.\n\nPass `align: 'left' | 'center' | 'right'` (default left) to the primitive. Its returned `alignment` and per-part `linePositions` give an absolute x origin and baseline for each `fit.sourceLines` entry, including blank lines. An inline value/unit pair moves together, with the gap following the actual value advance. Composition accepts host-resolved `contentAlignment`; an explicit slide `design.contentAlignment` overrides it. Renderer/export/pagination pass the effective design into the same operation. Alignment does not trigger a second font fit.\n\nCheck `overflow` before consuming parts. Irreducible text, invalid available space, parts outside the cell and overlapping occupied line boxes return field-specific diagnostics; strict mode throws `OPFCompositionError`. Invalid available boxes retain their dimensions and have no fit. These are advance-based line rectangles, not glyph outlines: the controlled browser evidence separately records small glyph overhangs. The API is a bounded internal allocator, not the complete layout-repair/Auto arrange operation or a native export fidelity guarantee.\n\nCurrent `grid-score-v9` retains the metric scoring introduced by `grid-score-v4`: every metric part contributes font reduction, with one overflow penalty per failing metric leaf. `item.metricLayout` is measured against the rounded accepted cell; `item.text`/`item.textStyle` alias the value fit/style. Field diagnostics obey strict ancestor policies, while explicit modes/weights/regions remain authoritative. Metrics are excluded from the advance-model `unmeasuredPayloads` list. Explicit pagination retains metrics atomically with complete source types/metadata and rejects irreducible fields without returning partial output. The published train has [installed-package acceptance](evidence/shipped-train-20260921/installed/acceptance-summary.json), including metric provenance controls. That evidence does not establish native raster or font equivalence.\n\n## Code internals (core 0.9.0 and coordinated packages)\n\nCore 0.9.0 introduced `layoutCode(value, box, options)` for the schema's string shorthand or `{source, language?, filename?}` object. It returns measured filename/language/body parts with exact original text, requested/resolved styles, readability floors, available boxes and diagnostics. Its original integration targeted renderer/PPTX 0.7.0 and editor 0.6.0; the [release checkpoint](plans/shared-code-release.md) records that rollout. These contracts are published in the current core 0.11.0, renderer 0.9.0, PPTX 0.9.1 and editor 0.8.0 train. Core 0.8.0 predates this API and retains the [recorded code-label, filename and whitespace defects](plans/layout-repair.md).\n\n`grid-score-v3` charges code font reductions across all metadata/body parts and one overflow penalty per failing leaf. It preserves explicit modes, weights, regions and source order. Accepted `item.codeLayout` is fitted to the same rounded cell exposed as `item.box`; `item.text` and `item.textStyle` alias the body, not the first metadata part. Strict ancestor settings apply to internal `.source`, `.filename` and `.language` diagnostics. Code no longer appears in `explanation.unmeasuredPayloads`, which concerns the core advance-based model only. It does not mean browser/native fidelity is verified.\n\nPagination slices the code body at grapheme boundaries, repeats filename/language and returns contiguous UTF-16 body ranges while preserving all source bytes and the evaluated readability floor. Irreducible metadata rejects all output, including when the body is empty or earlier content could have fitted. Consumers must preview/export the returned document. The [integration checkpoint](plans/shared-code-integration.md) separates source, installed browser, Windows PowerPoint and remaining release gates.\n\nEach fitted part retains every space and explicit CR/LF/CRLF break. `fit.lines` contains exact source slices, and `fit.sourceLines` records half-open UTF-16 `start`, `end` and `nextStart` offsets, the measured width and a `soft`, `hard` or `end` boundary. A hard break occupies `[end, nextStart)`; soft wrapping consumes no source character. Joining `part.text.slice(line.start, line.nextStart)` reconstructs the original part. Blank lines and a final empty line are retained, and long tokens split only at grapheme boundaries. Filename and language text are not case-converted. An absent/empty metadata pair creates a generated `code` label with no source range.\n\n`code-flow-v1` uses 18-reference-pixel outer insets, an eight-pixel gap between filename and language, and a twelve-pixel gap before the body. Nominal metadata/body sizes are 14/18 reference pixels, raised when necessary to respect the selected minimum, then scaled once. At most four metadata nominal/floor combinations are tried; each body fit tries at most 19 sizes regardless of canvas scale. The fitting combination with least font reduction wins. Irreducible metadata/body failures retain all text and diagnostic paths; invalid available boxes have no fit. `overflow: 'error'` rejects rather than returning partial output.\n\nTabs remain literal characters in part text and displayed-line slices. Measurement advances to the next multiple of four measured spaces from that line's origin; `fit.tabSize` and `fit.tabWidth` expose the rule. Each source line's `segments` contains exact text/tab source ranges plus measured `x`/`width` values relative to its origin. Consumers must reuse those positions: an Edge probe showed that SVG treats a tab as one space despite CSS `tab-size: 4`. The published SVG renderer uses positioned spans and geometric precision; native export uses accepted tab stops. Width measurements and source preservation alone do not establish glyph-outline containment, shaping/bidi support or native fidelity. [Installed workflow evidence](evidence/shared-code-installed/summary.json) records the separate actual browser and native checks with their exact font/runtime scope.\n\nPublished native export stores source boundaries in standard PowerPoint shape tags. Complete unique groups recover exact code/source metadata, with current native text taking precedence. Missing, damaged or ambiguous groups retain visible native shapes and report diagnostics. Reimport does not reconstruct native formatting, positioning, font theme or readability policy. Eight installed-export wide/portrait slides pass native edit/save/reopen and all 24 original/saved/edited imports on the recorded Windows PowerPoint build; this is not arbitrary PowerPoint round-trip or pixel equivalence. The editor preserves untouched CRLF/CR source around edits and keeps committed preview geometry separate from its active native textarea caret.\n\nThe JSON schema can accept strings that [XML 1.0 cannot represent](https://www.w3.org/TR/xml/#charsets). Published SVG/PPTX code output rejects forbidden controls, unpaired UTF-16 surrogates, U+FFFE and U+FFFF with `invalid-code-text`, the source field path and UTF-16 offset in the message. The input stays unchanged; the caller can correct that character explicitly. Tabs, CR/LF/CRLF and valid supplementary characters remain accepted for serialization. Schema support, format representability and glyph coverage are separate properties.\n\nThe controlled SVG harness requests `text-rendering=\"geometricPrecision\"` as well as explicit segment placement. Initial Linux Chromium CI rounded glyph advances under default hinting, unlike Windows Edge with the same font bytes. The [SVG specification](https://www.w3.org/TR/SVG/painting.html#TextRenderingProperty) defines geometric precision as a rendering hint, so consumers still need actual browser checks with their exact fonts and supported environments; the hint alone does not certify agreement. The harness retains a 0.1-reference-pixel tolerance and records observations before assertions.\n\n## Quote internals (core 0.8.0 and coordinated packages)\n\nCore 0.8.0 exports `layoutQuote(value, box, options)` from the `@openpresentation/opf/composition` entrypoint. Pass validated quote content (object or string shorthand), its allocated reference-pixel box, resolved `fontFamilies`, `textMeasurement`, `scale` (canvas short edge / 720), effective `minFontSize`, `overflow` policy and its source `path`.\n\nThe result contains `parts` for the body and any nonempty footer, exact display `text`, source mappings, requested and resolved text styles, and the available boxes/fits. Source ranges use half-open UTF-16 offsets in both the source field and display string; generated quotation marks and the footer separator have no source range. The original content is never modified. A supplied width provider is reported as `provided`; it does not certify shaping or font fidelity.\n\nCheck `overflow` and `diagnostics` before accepting the parts. Invalid available dimensions remain visible with `fit` absent, and `overflow: 'error'` throws `OPFCompositionError`. Diagnostics distinguish invalid part space, parts outside their cell, text that exceeds its reserved space, and overlapping line rectangles. Those rectangles are conservative text-layout bounds, not measured glyph outlines. The readability floor is scaled once and can raise the nominal body (28) or footer (17) size; it is never silently capped below the selected floor.\n\n`quote-flow-v1` keeps 18-reference-pixel outer insets and an 18-pixel body/footer gap while fonts scale with the canvas. A 40-pixel footer is a whitespace preference. The allocator expands it for long sources or compacts it for dense bodies, trying at most the nominal and minimum footer sizes and selecting the fitting pair with least total font reduction. If neither fits, it returns floor-size failure diagnostics. This is a bounded internal allocation step, not a complete layout-repair engine.\n\n`composeSlide` scores both parts and accepts geometry against the final rounded item box. Each quote item carries `quoteLayout`; its compatibility `text` field is the same fit object as the quote body, including generated quotation marks. Consumers needing original offsets must use the explicit `sources` mappings. The coordinated renderer and PPTX consume these parts without another measurement/style-resolution pass. Missing geometry or invalid part boxes reject rendering/export rather than omitting content. This requires core 0.8.0 with renderer/PPTX 0.6.0; older core 0.7.0/renderer 0.5.1/PPTX 0.5.2 lack these changes. The complete published set, immutable verification refs and fresh registry evidence are recorded in `release-plan.json` and [the release plan](plans/shared-quote-release.md).\n\nBrowser glyph bounds can extend slightly beyond advance-based part boxes into the reserved inset. Current loaded-font tests record those overhangs, verify glyph containment inside the full quote cell and check body/footer separation. Native PowerPoint fixtures separately verify text, sizes, cell containment, save/reopen and reimport. Neither test establishes universal pixel equivalence. Original requested-font provenance through host substitutions and non-quote payload internals remain open requirements.\n\n### Quote role and photo (FA-12)\n\n`QuoteContent` also takes `role` and `photo`. The footer text is the attribution, then `role` after a line break, then `source` after ` - `; each field keeps its own source mapping (`<path>.role`). Without a `role` the footer text is unchanged. A `photo` (an `Asset` value) adds `QuoteLayout.photo`: `{path, value, box, shape}`, where `shape` is the circle mask of `slideImageShape('circle', box)` (DrawingML `ellipse` and the same outline as an SVG path). The diameter is three times the footer font size and the gap beside it three quarters of that size, so the allocator picks the footer size (nominal or the readability floor) with the photo in the footer height and the footer text in the remaining width. The photo sits at the start edge of the inset area: the left, or the right when `options.direction` is `rtl`, where the footer text is its mirror. The text block is centered on the photo. A photo whose height cannot fit at the floor adds the `photo-fit` diagnostic reason (and `overflow`); a photo with no footer text still draws. Without a photo `layoutQuote` returns exactly what it returned before, with no `photo` key. The renderer and the PPTX exporter draw `photo` from the result and do not recompute it.\n\n## Timeline internals\n\n`layoutTimeline(value, box, options)` (`timeline-flow-v1`) places an optional name and description, then the events as a marker on a connector with each event's `when`, `what` and `description` as separate text parts. It tries an alternating arrangement (labels above and below a horizontal connector) and a vertical one (a marker rail down the start edge, labels beside it), at most 50 candidates in all, and keeps the first that fits or the one with the fewest diagnostics. Right-to-left decks put the vertical rail on the right edge.\n\n### Event status (FA-11)\n\n`TimelineEvent.status` is `done`, `current` or `planned`. It is a progress state that the engines draw from the deck's own colors without judging it; schedule health (at risk, blocked) is deliberately not a status. Layout copies the status to `TimelineLayout.markers[i].status` and to each of that event's text parts (`TimelineTextPart.status`), and `timelineMarkerShapes(marker, colors)` and `timelineTextColor(part, colors)` turn it into the shapes and text color that both engines draw:\n\n| Status | Marker | Text |\n| --- | --- | --- |\n| absent | A filled circle in the primary color. | Normal text color. |\n| `done` | The same filled circle, so it looks like an event without a status. | Normal text color. |\n| `current` (\"we are here\") | The filled circle inside a ring: a circle 1.6 times the marker's radius with a primary-color outline over the slide background. | The label (`what`) is bold, in the normal text color. |\n| `planned` | A hollow-looking circle of the marker's radius: a primary-color outline filled with the slide background. | All of the event's text is the muted secondary text color. |\n\n- **Geometry.** A marker's `radius` is the radius of its ellipse, for every status. A `current` marker also has `ring.radius`, exactly 1.6 times `radius`, and a `current` or `planned` marker has the `strokeWidth` of its outline (2 reference pixels at 720, at most half the radius). The drawn and exported ellipses have exactly these radii, so every ellipse of a deck has the same size for the same radius (a done, planned and unset marker are the same size); an outline is centered on its ellipse's edge and reaches half a stroke beyond it, and the cell check and the vertical rail count that half stroke. A ring that leaves the cell is reported as a `event-space` diagnostic like any marker. In the vertical arrangement a deck with a `current` event moves the rail and the text out by the ring's extra width so the text keeps its usual distance from the marker. Events without a status produce exactly the geometry and no extra keys they did before.\n- **Hollow means background-filled.** The outline of a hollow marker and the ring are drawn over the slide background color (in the SVG as the fill, in PPTX as a solid fill of the same color) so the connector does not cross them.\n- **Colors.** `timelineTextColor` returns `mutedText` for a planned event, lightened or darkened until it reaches 4.5:1 against the slide background (`TIMELINE_TEXT_MIN_CONTRAST`), and the normal text color otherwise. Outlines and rings are kept at 3:1 (`TIMELINE_OUTLINE_MIN_CONTRAST`); the filled marker keeps the primary color exactly.\n- **Bold label.** The weight is part of the layout, not a drawing detail: the `what` part of a `current` event is requested at weight 700, so line breaks and heights were measured bold.\n- **PPTX.** The marker is a native `ellipse` (solid primary fill for `done`, `current` and no status; background fill plus a primary line for `planned`), the ring is a second native `ellipse` (background fill plus a primary line), the bold label is a bold run, and planned text takes the muted color. The exporter records each event's status in the marker's `OPF_TIMELINE_V1` tag and the ring in a tag of its own, and the importer restores `status` from them; a deck whose marker or ring was edited away imports as ordinary text without status, like the rest of the timeline provenance. Status is also written in Markdown as a task-list prefix on the event line: `[x]` done, `[>]` current, `[ ]` planned.\n\n## Resizing in the preview\n\nChoose **Arrange** in the editor to reveal track dividers. Drag a divider to redistribute the space between adjacent columns (row/grid) or rows (column), including nested groups. Arrow keys make small changes; Shift makes larger changes. Escape discards a pointer draft. One drag creates one undo step, and no content is removed. Strict overflow rejects a resize that violates its fit constraints.\n\nResizing an automatic layout makes its chosen columns explicit as `mode: grid` with `columns`. This prevents the number of columns from changing under the pointer. The adjacent share clamps to 5\u201395%, with positive schema-valid weights. Other track proportions and unrelated document fields remain intact. Promoted regions retain their positions; their nested groups can still be resized. Layouts with reserved placeholder slots need an explicit arrangement first. Flows with more than twelve tracks need grouping before the current resize controls can express all weights.\n\n`createCanvasEditor(container, {layoutEditing: true, ...options})` enables dividers initially. `canvas.setLayoutEditing(boolean)` toggles them, and `canvas.commit()` / `canvas.cancel()` also handle an active resize. `onDraft` receives the proposed document; the session stays unchanged until commit. Changes to the resized container cancel a stale draft; unrelated updates are retained.\n\nThe shared engine exposes `geometry.flows`: each flow has its container path, content box, resolved column/row tracks (offset and size), clamped gap, effective composition, item count, and reserved slot count. This is renderer geometry, not new OPF document fields.\n\nAgents can prepare the same guarded change without a DOM:\n\n```js\nimport {prepareTrackResize} from '@openpresentation/opf-editor/layout';\nimport {resolvePresentation} from '@openpresentation/opf-render/svg';\nconst geometry = resolvePresentation(editor.document, renderOptions).slides[0].geometry;\nconst flow = geometry.flows.find(flow => flow.path === 'slides.0');\nconst prepared = prepareTrackResize(editor.document, flow, 0, 0.65);\n// Boundary 0: give the first track 65% of the adjacent pair's combined space.\n// Preview prepared.document with the same renderer and font provider before applying.\neditor.applyPatch(prepared.patches, {rejectInvalid: true});\n```\n\nThe patch contains a `test` guard for the container before changing its composition. Failed tests do not mutate the document or its history. A test-only patch is read-only. Rendering is preflighted by the canvas; headless callers should likewise render a candidate to enforce font and overflow constraints.\n\nVerification: editor layout model tests, `/layout-tests.html` browser keyboard checks and trusted-pointer specimens, and `pnpm test:layout` for measured SVG/native PPTX coordinate parity. Shape-coordinate checks do not establish PowerPoint raster pixel parity.\n\n\n## Reordering and moving blocks\n\nIn **Arrange**, drag a numbered block handle to reorder siblings. The insertion marker shows the destination; the shared renderer reflows the slide after drop. Arrow keys on a handle move the whole block earlier or later. Click a handle for **Earlier**, **Later**, or an explicit destination and insertion position. The destination menu supports existing groups and block-based slides, including moving a child out of a group or moving a whole group to another slide. `canvas.openBlockMenu(path)` opens the same controls programmatically.\n\nA move preserves the entire block and its nested content, formatting, data, and references. Parent composition weights describe positions, so they stay in place. Moving to another container can change the block's inherited design and readability constraints; the canvas renders the candidate before committing it. Strict overflow or an unavailable required font rejects the move. A move cannot leave an empty block container or put a group inside its own descendants. Move the group or add another block first when the source has only one child.\n\n```js\nimport {prepareBlockMove, listBlockContainers} from '@openpresentation/opf-editor/layout';\nconst containers = listBlockContainers(editor.document);\nconst prepared = prepareBlockMove(editor.document,\n '/slides/0/blocks/0', '/slides/0/blocks/1', 1);\n// Insert the first block before child 1 of the second block's group.\n// Destination indexes refer to the document before removal.\n// prepared.path reports the moved block's address after any index shifts.\neditor.applyPatch(prepared.patches, {rejectInvalid: true});\n```\n\n`prepareBlockMove` returns `{document, patches, path, changed}`. It validates the complete result and emits guarded remove/add patches, so the editor or CLI can apply it atomically. No-op moves return `changed: false` and no patches. `listBlockContainers(document, {slideIndex})` optionally limits discovery to a single slide and excludes arbitrary extension data. Headless callers should render the candidate with their intended font provider before applying. The browser and installed-package block harnesses exercise nested moves, undo, stale menus, keyboard access, strict-fit rejection, and native drag reordering.\n\nCreation and deletion use the same layout engine: insertions can normalize implicit payloads into explicit blocks; deletions prune empty groups while retaining the slide. Existing track weights stay positional. See the [editor creation guide](live-editor.md#create-duplicate-and-delete-content) for the guarded APIs and canvas controls.\n"
79
+ "markdown": "# Dynamic composition\n\nOPF keeps authoring intent in JSON. Use `blocks` when content can reflow; use promoted regions when relative placement is meaningful. `composition` on a slide overrides fields in the resolved layout's `composition`. Existing documents remain valid.\n\nThe current published Node 24 train is core 0.14.0, renderer 0.14.0, PPTX 0.14.0, editor 0.14.2 and CLI 0.11.0. Use the exact pins in [release-plan.json](../release-plan.json); the [compatibility matrix](compatibility-matrix.md) separates package support from native Office and font gates. Older version references below identify when individual contracts were introduced.\n\n```json\n{\n \"name\": \"Decision brief\",\n \"slides\": [{\n \"title\": \"Make the main idea clear\",\n \"composition\": { \"mode\": \"row\", \"weights\": [2, 1], \"overflow\": \"error\" },\n \"blocks\": [\n { \"text\": \"The evidence and recommendation receive twice the width.\" },\n { \"text\": \"The supporting detail receives the remaining width.\" }\n ]\n }]\n}\n```\n\n`auto` evaluates candidate grids using text fit and cell proportions. `columns` limits its candidates. `grid` uses `columns` if given, otherwise a grid based on the canvas shape. `row` uses one row; `column` uses one column. Items retain source order. Weights size columns except in column mode, where they size rows. Missing weights are 1; unused weights have no effect. A partially filled final row retains its grid tracks.\n\n`gap` defaults to 1/30 and `padding` to 0.08, both fractions of the canvas's shorter edge. Large gaps are reduced when necessary to keep cells positive. `minFontSize` defaults to 16 reference pixels at a 720-pixel short edge. The reference coordinate system uses 96 pixels per inch. Explicit inch dimensions override presets independently for each axis.\n\nComposed font sizes lie on PowerPoint's 0.01 pt grid (RR-16). PowerPoint stores a run size (`sz`) in hundredths of a point and a point is 4/3 reference pixels, so every size a fit accepts is a whole multiple of 1/75 px (`FONT_SIZE_GRID_PER_PX`), and the preview draws exactly the size the export writes. Fitting evaluates each trial size on the grid before breaking lines and placing text: trial sizes stay anchored to the unsnapped request (no drift) and round down (`snapFontSizeDown`), so a size that fit before snapping still fits; a readability floor rounds up (`snapFontSizeUp`), so `minFontSize` is never undercut; and a result that reports no overflow was measured at the size it carries. The rule covers plain, rich, list (marker, description and picture-bullet side), table, quote, code, metric, timeline, furniture and heading text, including each rich run and script. `wrapText` measures at exactly the size it is given.\n\nHeadings reserve space according to their wrapped text. Title and subtitle share the padded width of the free area, and a missing tag or subtitle leaves no gap.\n\nCover slides vertically center the combined tag/title/subtitle group in the free heading area. A cover is a slide with no body payload (no root content field, where a root `image` counts unless the layout places it, no `blocks` other than placed images, no promoted regions; empty payloads such as `blocks: []`, `text: \"\"`, empty lists and regions with nothing in them count as no body; whitespace-only text is still body) on a heading-only layout: a layout record whose placeholders are all headings or placed images (an `image` placeholder with a `placement`), or a slide with no layout record at all. The rule reads the record, never its id. The free area is the slide minus the bands of placed images, header and footer furniture, and the usual padding; a picture background reserves nothing. A wrapped heading makes the group taller and the group recenters. A group that already fills the free area is not moved. Accepted line and outline origins move with the boxes. Explicit heading `alignment` positions ink inside the box and never changes the vertical position. A flowed image is body, so image slides keep the top-aligned content origin; a placed image is not. Content slides are not affected: headings stay at the top and the body follows them. This is a reference-engine default, not a schema field.\n\nContent that exceeds the number of preset placeholders reflows together; it is not drawn over already-bound content. Promoted regions keep the 3\xD73 vocabulary, including standalone `top`, `middle`, and `bottom`. They ignore flow direction and track weights. They are composed in visual reading order (rows from top to bottom, then along the row; `visualReadingOrder`), not in key order, so the composed item order, the preview's draw order, the PPTX shape order and the reading order of assistive technology agree. The order is computed from the logical region cells, so a right-to-left deck reads the same logical order.\n\nEvery shared `design` key resolves with one merge, per key: the slide's `design`, then the deck's `design`, then the slide's layout record `design`, then the engine default (`resolveDesignHints`; the result is `SlideComposition.design`). Two design hints shape the root arrangement. `design.contentDirection` (slide design, deck design, then the layout record's own) sets the root mode, `vertical` as `column` and `horizontal` as `row`, when neither the slide nor its layout record sets a `composition.mode`; it ranks with the layout record's `design.contentDirection`, above automatic selection, regions and nested groups are untouched, and the decision keeps `reason: 'configured-mode'`. `design.chartPrimary` (slide, deck, then the layout record's `design.chartPrimary`) applies when the slide sets no `composition.mode` of its own and the root nodes mix at least one chart with other content: the first chart becomes a primary track and the other nodes form one synthetic sub-grid arranged in `auto` mode, a two-track row for `left`/`right` or column for `top`/`bottom`, weighted 3:2 in favor of the chart, with explicit root `columns`/`weights` ignored; the synthetic container has no path and records no group, flow or decision, and the root decision reports `reason: 'chart-primary'`. On cover slides `geometry.logo` places the deck logo above the centered heading group. [Design resolution](design-resolution.md#brand-assets-and-layout-hints) states the precedence, the logo variant selection, picture bullets and the accent font, with the vetoable decisions.\n\n## Shared headers and footers\n\nPublished core 0.11.0 exposes `layoutFurniture(slide, options)` and `geometry.furniture`, separate from body `items`. Composition identifies the available-space policy as `grid-score-v9`. Raw callers pass the presentation as `options.presentation`, resolved dimensions/fonts and the same measurement provider used by preview. `slideIndex` identifies source paths; optional `slideNumber` is the one-based displayed number.\n\nThe core resolver honors whole local header/footer overrides, including `false` and empty objects. Each zone retains its image and all configured text fields in source-aware parts. Literal text and dates preserve whitespace and empty strings. Organization and section values point to their metadata source; page numbers use the actual output sequence. A missing organization/section, or `date: true` without a host-supplied current date, produces `unresolved-content`; the implementation never consults a clock or invents source text.\n\nSlide numbers and dates carry formats. `slideNumberFormat` is a template such as `\"A-{current}\"` or `\"{current} / {total}\"`: `{current}` is the displayed number and `{total}` is the displayed slide count (`options.slideCount`, else `presentation.slides.length`; whole-deck pagination iterates to the final page count). `dateFormat` is an LDML-style pattern (`yyyy`, `yy`, `MMMM`, `MMM`, `MM`, `M`, `dd`, `d`, `EEEE`, `EEE`, quoted literals) with fixed English month and weekday names. A string `date` with `dateFormat` must be an ISO `YYYY-MM-DD` date and renders as fixed, generated text tied to that source value; without `dateFormat` a date string stays literal and editable. `date: true` is the current date: hosts pass today's ISO date as `options.date` (composition, pagination, renderer and exporter), and the default pattern is `M/d/yyyy`. Text parts expose `fields` (half-open UTF-16 ranges of each `{current}` number and of a whole current date), so exporters can write native live fields while `{total}` and fixed dates stay fixed text. `formatFurnitureDate()` and `formatSlideNumber()` are exported for hosts. All configured fields in one zone stack, in the order logo, image, text, organization, socials, section, slide number, date (`logo: true` is the deck's icon logo as a generated image part; see [design resolution](design-resolution.md#brand-assets-and-layout-hints)); put a date and a slide number in different zones to keep one line each. Hiding furniture on a title slide is the slide-level `design.header: false` / `design.footer: false` override.\n\n`speaker: true` generates one `speaker` part from the first speaker: `\"Ada Lovelace, CTO\"`, or the name alone when there is no title. Its `sourcePath` is `speaker.name` (a single speaker) or `speaker.0.name`. A deck with no named speaker produces `unresolved-content`. In a zone it stacks after `organization` and before `socials`: logo, image, text, organization, speaker, socials, section, slide number, date. PPTX export writes it as static text and import restores the flag from furniture provenance.\n\n`socials: true` (core 0.11.1 and later) generates one `socials` part from the primary organization's `organization.socials`. The part has one source line per platform, in key order, and a parallel `links` array (`platform`, `text`, `href`, `resolved`, `sourcePath`). `resolveSocialProfile(platform, value, owner)` formats each value without network access, through the engine's social-platform vocabulary (`SOCIAL_PLATFORMS`, [`engine-vocabularies.json`](../spec/reference/engine-vocabularies.json)). A handle loses its `handlePrefix` and is substituted into `companyUrlPattern`, then `profileUrlPattern`, then `baseUrl/{handle}`; the result is shown as that URL without `https://`. A URL value passes through unchanged except that `https://` is dropped from the display. A key outside the vocabulary (the schema rejects one) is shown raw with no link, which is the Socials engine fallback. No catalog is involved: the Socials keys are an engine vocabulary. A missing organization, or one with no non-empty socials, produces `unresolved-content`. Speaker socials, platform icons, brand colors and slide-size presets are not rendered.\n\n`furniture-flow-v2` gives each left/center/right zone 26% of the canvas width. Parts stack within a zone; the tallest zone sets the natural band height. Text uses at least the selected readability floor, with complete accepted source lines and optional measured outline placement. Header and footer bands reserve room before heading and body allocation. Irreducible text, conflicting bands or a heading displaced beyond the remaining space produce diagnostics; strict composition rejects them. An image or `logo: true` part (`type: 'image'`) aligns like the zone's text: its `box` is as wide as the image's own proportions make it at the band height (a 36 px band at 1280 px wide), at most the zone, flush with the zone's left edge in the left zone, centered in the center zone and flush with its right edge in the right zone, at the same vertical position as before; consumers fit the image inside `box`. Core reads the proportions without fetching, from an embedded PNG, JPEG, GIF, WebP or SVG data URI or an `asset:` reference to one, decoding only a bounded prefix of the payload (64 KiB, 1 MiB for a JPEG whose frame header sits behind large metadata) and memoizing per source, so a multi-megabyte logo costs nothing per slide; a JPEG whose header is past 1 MiB, an SVG whose root tag is past 64 KiB, a path or a URL is unreadable is placed in a square box (vetoable), so a wide image behind a path or URL is drawn small and flush to its zone edge until it is embedded. No-furniture body geometry remains unchanged.\n\nPagination repeats these fields without putting them among body slices. An optional `page.repeatedMappings` records repeated heading/furniture and metadata paths while the existing `page.mappings` retains its body-fragment contract. Whole-deck pagination evaluates final output numbers, including preceding continuation pages, and rejects unresolved repeated content atomically. Renderer and editor reuse the accepted parts; literal text/date fields, including empty values, support direct canvas editing and undo. Generated labels remain tied to metadata.\n\nPublished PPTX (0.9.1 and later, current 0.13.2) draws the accepted editable text boxes and fitted images and records furniture provenance in tagged slide shapes. Reimport uses current native text and images; damaged or ambiguous provenance retains visible content with diagnostics. The [fresh installed-package evidence](evidence/shipped-train-20260921/installed/acceptance-summary.json) includes deterministic export, current-content reimport controls and offline canvas editing/undo. Native PowerPoint acceptance, font compatibility and full visual review remain separate gates; the published PPTX (through 0.11.7) is not native `p:hf` Header/Footer support. On [opf-pptx main](https://github.com/OpenPresentation/opf-pptx) (RR-11, unreleased) the first footer text, date and slide number that fit one accepted line become real PowerPoint `ftr`, `dt` and `sldNum` placeholders at exactly this geometry, with master/layout placeholders and `p:hf` flags (see [native header and footer](https://github.com/OpenPresentation/opf-pptx/blob/main/docs/native-header-footer.md)); header parts, organization, section, socials, images and multi-line text stay tagged shapes because PowerPoint has no object for them. No core geometry or schema changed. Bounds/readability checks do not certify whole-slide design quality: long labels can wrap heavily in portrait zones, and outline agreement does not establish native font identity.\n\n## Footnote areas and caption bands\n\nRR-34 (core after 0.11.4) adds two reserved regions that exist only for decks that use the fields. A slide whose runs carry `cite` or `footnote` markers gets `geometry.footnotes` (`footnote-area-v1`): a rule and the slide's notes in number order, directly above the footer band (or the bottom padding) with the content area's left edge and width, in the body family at the furniture size; the content area shrinks by exactly the area's height plus half the slide gap, and nothing above it moves. The area takes at most 35% of the span between the heading top and the footer band; a note that does not fit reports `text-overflow` at its source path (`references.N` or the run's path), which pagination treats like any other fit overflow. `slideCitations(slide, slideIndex, presentation)` is the numbering `composeSlide` uses; pass the same `presentation` and `slideIndex` to every consumer. An `image`, `chart`, `table` or `video` payload with a `caption` reserves a caption band inside its region (`item.caption`; `item.box` becomes the media box), below or above the media, at most 35% of the region; automatic grid selection scores the leaf on its media box. See [footnotes, citations and captions](footnotes-citations-captions.md).\n\n## Backgrounds and placed images\n\nCore 0.15 composes pictures in two ways (FA-22); [images](image-treatments.md) has the full vocabulary.\n\n- A picture background (the slide's `design.background`, then the deck's, then the theme's, when it is `{ type: \"image\", ... }` or an image-source string) is `geometry.backgroundImage`: the whole canvas as its frame, `fit` (`cover`, `contain`, `stretch`, `tile`), `focus`, `opacity`, `recolor` and the overlay box (whole picture or an edge band). It never moves content. `resolveSlideContext` passes the theme's background as `ComposeSlideOptions.themeBackground`.\n- Every image item carries `item.image`: the frame inside its region, `fit` (the block's own, else the effective `design.imageFit`, else `cover`), `focus`, the mask with its DrawingML preset and SVG outline, line, opacity, recolor and overlay. `fitImage(frame, fit, aspect, focus)` is the shared fit math; core fills `item.image.picture` itself when it can read the picture's proportions (a data URI, or an `asset:` reference to one).\n\nA top-level image block with `placement: { edge, size, inset }`, or the slide's n-th top-level image when the layout's n-th image placeholder carries a placement, takes a band along that slide edge: `size` of the slide width (left, right) or height (top, bottom), default 0.5, edge to edge, or inside the slide padding with `inset`. Headings, footnotes and the body compose in the free area that remains, with the usual padding; header and footer bands keep their full-width placement. Bands are taken in block order and each spans the free area the earlier ones left; a second block on a used edge flows as content (the validator reports it). Placed items are listed after the headings and before the flowed body, and their placeholder slot is not reserved in the flow. A slide whose only body is placed images is composed like a cover. A right-to-left deck mirrors `left` and `right`.\n## Nested groups\n\n### Shared content cards\n\nShared content cards are published in core 0.10.0 and later, including current core 0.14.0. For `design.contentBox: true`, each body leaf carries a `frameBox` at its outer allocation and a `box` padded inward by 12 reference pixels at a 720-pixel short edge, capped at one quarter of the frame's width or height. Scoring, accepted payload measurement, strict overflow and pagination all use that rounded interior. Headings remain unframed, nested groups keep their original padding, and explicit outer regions/track weights remain authoritative. Automatic candidates may change because their available content space changes.\n\nEvery composed item carries its resolved horizontal text `alignment` (`left`, `center` or `right`). The title uses `titleAlignment`; every other item, including subtitle, tag, body text, lists, tables and metrics, uses `contentAlignment`. The value is the effective `design` key: the slide's design, then the deck's design (or the host option), then the layout record's `design`, then `left`, so a layout that sets `contentAlignment: center` centers its content with no deck or slide value. The title never inherits `contentAlignment`. A cover (a slide with no body payload on a heading-only layout) has no content region, so its tag and subtitle join the title's alignment: they follow `titleAlignment`, and only a `contentAlignment` set on the slide's own design keeps them apart (a deck or layout value does not). Accepted outline placement and metric internals use the same value. The renderer and the PPTX exporter anchor preview and native text to `item.alignment`, so both engines place a layout's text the same way.\n\nRaw composition callers pass their resolved deck flag as `composeSlide(slide, {contentBox: effectiveDesign.contentBox, ...options})`; a slide's explicit `design.contentBox: false` overrides it. Coordinated renderer, editor and whole-presentation pagination resolve this option for their callers. Consumers draw at `frameBox` and use the accepted `box` and payload internals without another inset. Core 0.9.0 predates this behavior. Content cards do not make the incomplete chart/timeline density models complete or certify native raster fidelity.\n\nA block or promoted region can contain its own `blocks` and `composition`. The optional discriminator is `\"type\": \"group\"`. A group has at least one child and cannot mix children with leaf fields such as `text` or `image`.\n\n```json\n{\n \"composition\": { \"mode\": \"row\", \"weights\": [2, 1] },\n \"blocks\": [\n {\n \"composition\": { \"mode\": \"column\", \"padding\": 0.02 },\n \"blocks\": [{ \"text\": \"Recommendation\" }, { \"text\": \"Supporting evidence\" }]\n },\n { \"text\": \"Context\" }\n ]\n}\n```\n\nThe parent allocates a box to each group, then the group arranges its children inside that box. Group padding defaults to zero; padding and gap use the group's shorter edge. Only `minFontSize` and `overflow` inherit. A strict ancestor cannot be weakened by a child's `overflow: \"warn\"`. Font sizes remain relative to the canvas, not the group. Groups can nest up to 32 levels; cycles and deeper nesting fail with an explicit error.\n\nAutomatic grid scoring inspects descendant text using each descendant's explicit arrangement or geometric automatic seed. After selecting the parent's grid, it optimizes each child's automatic grid. This deterministic, bounded search avoids exponential combinations; it does not claim a globally optimal packing.\n\n`result.items` contains every leaf with its full source path and effective composition. `result.groups` contains group paths, outer bounds, and content bounds. The editor's `setGroupComposition(path, value)` validates and records undo/redo just like slide composition edits.\n\n## Inspecting and repairing layout\n\n```js\nimport { composeSlide } from '@openpresentation/opf/composition';\nconst result = composeSlide(deck.slides[0], { width: 1280, height: 720, layout: resolvedLayout });\nconsole.log(result.items); // Source paths, content, geometry, and text estimates\nconsole.log(result.diagnostics); // Path-specific text-overflow and small-cell messages\n```\n\nThis pure function expects a validated slide. The caller resolves catalog records and passes the canvas size, `fontFamilies` and `textMeasurement`; `resolveSlideContext(deck, index, { fonts })` from the package root does the catalog resolution for one slide of a deck and returns those options. The rendering and export packages perform those steps at their boundaries. No network, DOM, system font, or AI dependency is required.\n\n### Explain automatic selection (core 0.8.0 and later)\n\nPass `explain: true` to return `result.explanation`. This opt-in API requires core 0.8.0; it is absent from core 0.7.0. Enabling explanations adds no measurement calls and does not change geometry, source content, reading order, weights or selected arrangements within the same engine version.\n\n```js\nconst result = composeSlide(slide, {...resolvedOptions, explain: true});\nfor (const decision of result.explanation.decisions) {\n console.log(decision.path, decision.reason, decision.selectedColumns);\n console.table(decision.candidates);\n}\nconsole.log(result.explanation.textMeasurement);\nconsole.log(result.explanation.unmeasuredPayloads);\n```\n\n`resolvedOptions` supplies the same dimensions, layout, fonts and optional width provider as the preview. Core 0.8.0 identifies its explanation as `grid-score-v2`; core 0.9.0 advances to `grid-score-v3` to include complete code metadata/body measurements. Current core 0.14.0 reports `grid-score-v9`. These versions record containers in parent-before-child order. `lowest-score` reports the candidates actually tried; `configured-mode` respects resolved row/column/grid intent and returns no invented candidates. `promoted-regions` leaves region placement fixed and has no selected column count. Empty slides have no decisions. Automatic search tries one through `min(slotCount, columns ?? 6)` columns, in ascending order; ties retain the first candidate. Reserved placeholders count as slots. The schema caps an explicit candidate limit at twelve columns.\n\nEach candidate has `columns`, `rows`, `score` and additive `penalties`:\n\n| Penalty | Rule |\n| --- | --- |\n| `cellProportions` | Sum of `abs(log(cellAspect / 1.6))` for descendant leaves |\n| `fontReduction` | Reduction from 25 reference pixels for text-like leaves; current quote/code/metric/timeline layouts sum requested-minus-fitted sizes across their parts, divided by canvas scale |\n| `textOverflow` | 1,000 per overflowing text-like leaf or complete quote/code/metric/timeline payload, regardless of the number of internal failure reasons |\n| `tableOverflow` | 1,000 per table whose shared cell layout overflows |\n| `smallCells` | 100 per leaf narrower than 100 or shorter than 60 reference pixels |\n| `emptySlots` | 2 per unused position in the candidate grid's final row |\n\nScores are preference costs, not quality percentages or guarantees. Floating-point summation can make the component total differ slightly from `score`. Parent scoring uses descendant explicit arrangements or geometric automatic seeds; child automatic grids are optimized only after selecting the parent. Candidate scores therefore describe the bounded search, not a full assessment of the final optimized subtree. Heading fit remains in ordinary diagnostics, outside body-grid scoring. A strict-fit rejection exposes the explanation on `OPFCompositionError` when requested.\n\n`textMeasurement` is `estimated` without a provider and `provided` with one. A provided width function does not establish font provenance, glyph coverage, shaping or native raster fidelity. Text, rich text, lists, quotes, table cells and code in core 0.9 participate in the fit model. Current core 0.11.0 also measures metric and timeline parts; its `unmeasuredPayloads` identifies images, video and charts whose complete internal layout is not assessed. Core 0.8 reports code as incomplete; core 0.9 measures its filename/language/body and insets. Media aspect ratios, chart labels and complete timeline visual density still require inspection. A zero score or empty diagnostics is not proof that those payloads fit.\n\nExplanations expose the search for inspection and do not silently paginate or rewrite a document. Published editor 0.8.0 provides guarded track resizing, block moves, creation/removal and explicit pagination with preview/undo. A general automatic repair loop, automatic weight allocation, complete payload-internal measurement, CLI explanations and a canvas **Auto arrange** preview/undo operation remain open work; the **Arrange** controls below are explicit human adjustments.\n\nWith `overflow: \"warn\"` (default), the result retains all text and returns diagnostics. SVG emits all lines and marks overflowing groups with `data-opf-overflow=\"true\"`; text may extend beyond its box or canvas. Consumers can collect diagnostics using `onDiagnostic`. With `overflow: \"error\"`, the layout rejects content that does not fit. Shorten the affected content, give it more space, or explicitly split it into another slide. Use the explicit pagination transform below to produce additional editable slides.\n\nThe editor exposes `editor.composeSlide(index)` and `editor.setComposition(index, value)`. The latter validates the change, records JSON Patch history, and supports undo/redo.\n\n## Pagination\n\n```js\nimport { paginateSlide, paginate } from '@openpresentation/opf/pagination';\nconst { presentation, pages } = paginate(deck);\n// Review, save, render, or export `presentation`; pages maps output fragments to source paths.\nconst single = paginateSlide(deck.slides[0], { width: 1280, height: 720, minFontSize: 24 });\n```\n\nPagination is an authoring operation. It produces ordinary OPF slides; previews and PPTX export consume those exact pages. It preserves the input, body order, nested groups, promoted regions, rich-text formatting, and source text characters. Plain text and rich runs split at grapheme boundaries, preferring sentence/paragraph breaks and then word breaks. Lists split between items, tables between rows with column labels repeated, and code splits without rewriting its source. Indivisible payloads remain intact. Existing track weights continue to apply to positions on each resulting page.\n\nThe default readability target is 24 reference pixels. Core 0.8.0 returns slides that persist that floor in `composition.minFontSize`, including an already-fitting one-page result; existing higher minima and strict overflow policies remain intact. Quotes can raise their nominal body/footer sizes to the floor. Current core 0.11.0 enforces the selected floor in shared plain/rich/list/table fitting, including painted rich fragments, while retaining authored style metadata. Unsupported fits report overflow rather than silently capping output below the floor. The [readability-floor checkpoint](plans/readability-floor.md) records the earlier candidate and its bounded-search tradeoffs. Pagination relies on the shared engine's estimates; it is not a guarantee that every host font renders identically. Headings repeat unchanged, speaker notes remain on the first page, and continuation IDs avoid existing deck IDs. `pages[].mappings` records full source/output paths and half-open text or item ranges. Text offsets use UTF-16, so source strings can be reconstructed exactly. Quote bodies split at grapheme boundaries and repeat complete attribution/source fields on each page. An irreducible footer rejects the whole operation, including a quote with an empty body after earlier content.\n\nIf a heading, individual list item, table row, or other atomic payload cannot fit on an otherwise empty page, `OPFPaginationError` returns actionable diagnostics. There is no partial output. `maxSlides` defaults to 100, and a layout-evaluation limit bounds work on pathological input. Specialized chart and timeline internals still require visual inspection; their complete density models remain outstanding.\n\nThe editor's `editor.paginateSlide(index)` is one validated transaction with undo/redo. It returns `{change, pagination}`. Editor 0.5.0 commits a one-page readability-policy change too; repeating the operation after the policy is recorded returns `change: null`. The playground includes an overflowing draft and **Split overflow** action. The CLI writes a new file and refuses to overwrite an existing one:\n\n```sh\nopf paginate input.opf.json output.opf.json\n```\n\n## Right-to-left decks\n\nWhen the presentation `language` is written right to left (Arabic, Hebrew, Syriac, Thaana and the other right-to-left scripts) or the host passes `direction: 'rtl'`, `composeSlide` mirrors the composition and reports each paragraph's direction; `SlideComposition.direction` is `'rtl'`. The first column and the `left` region are drawn at the right, banded slide images, cover logos and header/footer zones swap sides, lists put their markers at the right, tables run right to left, and `TextFit.directions` gives the direction of the paragraph each line belongs to. Alignment is logical: the authored `left` is the start edge, drawn at the right edge of a right-to-left paragraph (`physicalAlignment`). A left-to-right deck composes exactly as before. The rules and the PPTX mapping are in [Layout direction](programs/font-fidelity-everywhere/script-font-model.md#layout-direction-rr-05).\n\n## Fidelity boundary\n\nThe shared engine provides identical body and heading geometry to SVG and editable PPTX export. Text measurements default to deterministic estimates. For actual font advances, use the shared provider described in [measured fonts](font-fidelity.md). Complex scripts, fallback fonts, PowerPoint text rendering, rich text, charts, tables, and images still need visual verification. Dynamic composition is not a guarantee of pixel-identical PowerPoint output. List density includes rich runs, descriptions and nesting via `fitList`, with the same hanging indents used in preview and export. Only text-like payloads currently receive content-density estimates; small-cell diagnostics also cover non-text content.\n\nSVG embeds raster data URI images locally. Remote and file images require a host resolver that supplies a raster data URI; otherwise they appear as placeholders. `strictAssets` rejects unresolved images. The runtime never fetches them.\n\nSee [the complete example](../examples/technical/dynamic-composition.opf.json) and [local ecosystem verification](ecosystem-development.md).\n\n\n## Preview polish shared by preview and export (RR-07)\n\nThree accepted spec fields used to be drawn plain; core now owns the shared tables so the SVG preview and the PPTX export draw them identically. Consumers import them from `@openpresentation/opf`; none changes geometry, and an older core simply leaves the preview and the export plain, without a new diagnostic.\n\n- **Code syntax colours.** `tokenizeCode(source, language)` returns sorted, non-overlapping token ranges (`keyword`, `string`, `number`, `comment`, `function`, `type`, `property`) over the exact `code.source`; `codeLineRuns(tokens, start, end, source?)` clips them to one accepted source line, so concatenating the runs returns the line unchanged (a tab is always its own plain run). The scanner is deterministic and dependency-free (no network, clock or locale). `code.language` resolves through aliases (`ts`, `tsx`, `py`, `sh`, `yml`, `c++`, `terraform`, and so on) to `javascript`, `typescript`, `python`, `rust`, `go`, `bash`, `json`, `yaml`, `toml`, `ini`, `html`/`xml`, `css`, `sql`, `java`, `csharp`, `kotlin`, `swift`, `ruby`, `php`, `c`/`cpp`, `hcl` and `dockerfile` (`resolveCodeLanguage`, `CODE_HIGHLIGHT_LANGUAGES`); any other language, a missing one, or source over 200,000 characters stays plain. `codeSyntaxPaletteForScheme(colorScheme)` gives one colour per kind from the deck theme (keyword = primary, string = accent, number = secondary; comment, function, type and property are fixed slate, blue, cyan and pink, rotated away from a theme colour they would collide with), each lightened until it is at least 4.5:1 on the #111827 code panel. The renderer nests coloured tspans inside each accepted segment tspan; PPTX writes the same colours as native runs in the same one-text-box-per-line shapes, so editing and import are unchanged. The language is not inferred from `code.filename`. `code.highlight` (1-based source lines and inclusive ranges) adds a band behind marked lines and dims the rest: `codeHighlightLines` resolves it against the source, `codeLineNumbers` numbers each displayed line (a wrapped line repeats its number), `codeHighlightBands` returns the runs of displayed lines to draw behind, and `codeHighlightColors(colorScheme)` gives the band and the marked/dimmed palettes, all at least 4.5:1 on what they sit on. The SVG band is one `rect` per run behind the line text; PPTX writes one native rectangle per run (`OPF code N highlight M`) after the panel and before the line text boxes, so the one-text-box-per-line shapes and the code provenance tags are unchanged.\n- **Metric trend.** A `metric.trend` (`up`, `down`, `flat`) keeps its word as the visible, editable text and gains one arrow: `metricTrendMark(layout, {background})` derives the arrow from the accepted trend line (square, 0.72 of the font size, on the baseline, 0.3 of the font size from the word; after the word for left alignment, before it for centre and right, and omitted if it would leave the field). The arrow is the DrawingML `upArrow`, `downArrow` or `rightArrow` preset at default adjustments (`metricTrendPoints` is the outline the preview draws), coloured by the metric's `sentiment`: positive green, negative red, neutral a neutral colour, kept at 4.5:1 or more against the slide background (`metricTrendColor`); the delta and trend text take the same colour. `layoutMetric` passes `sentiment` through as `MetricLayout.sentiment` (omitted when the metric has none) and `metricTrendMark` reads it, so the arrow direction always follows `trend` and only the colour follows `sentiment`. Absent, the sentiment is the conventional reading of the trend (up positive, down negative, flat neutral), which is the colour drawn before `sentiment` existed. A `sentiment` without a `trend` draws no arrow and colours nothing. The arrow carries the alternative text \"Trend: up\" (`aria-label` in SVG, `descr` in PPTX); the word and the arrow carry the direction, the colour the verdict.\n- **Pattern fills.** `PATTERN_PRESETS` lists the 54 ECMA-376 ST_PresetPatternVal names and `patternBitmap(preset)` / `patternRuns(preset)` give each as an 8 x 8 one-bit tile, one pixel per 1/96 inch, anchored at the slide's top-left, foreground where a bit is set (`diagStripe` still resolves to `wdUpDiag`). ECMA-376 names the presets but does not define their pixels: the tiles are measured from desktop PowerPoint (Office 365, Windows, 2026-10-01) by exporting each preset as a full-slide background to a 1280 x 720 PNG and voting every pixel into its (x mod 8, y mod 8) cell with opf-render `scripts/derive-pattern-bitmaps.mjs`. Every tile was uniform across all repeats (confidence 1.00) at one image pixel per pattern pixel, so a pattern pixel is one 1/96 inch unit, and the phase is the slide's top-left corner. The tool also reports any tile that later differs. PPTX already wrote every preset as native `a:pattFill`.\n\n## Metric internals (published coordinated packages)\n\nPublished core 0.11.0 exports `layoutMetric(value, box, options)` from `@openpresentation/opf/composition` (the package root no longer exports engine names). It accepts a finite number, string, or `{value, unit?, label?, description?, delta?, trend?, sentiment?}`. Renderer 0.9.0, editor 0.8.0 and PPTX 0.9.1 consume its shared geometry for composition, atomic pagination, preview, editing and export. Core 0.9.0 predates this API. The [primitive checkpoint](plans/shared-metric-layout.md) and [source integration checkpoint](plans/shared-metric-integration.md) record its development; current pins and remaining native/font gates are in the [compatibility matrix](compatibility-matrix.md).\n\nPass the allocated reference-pixel `box`, resolved heading/body `fontFamilies`, `textMeasurement`, canvas `scale`, effective `minFontSize`, source `path` and optional `overflow: 'error'`. `metric-flow-v1` returns separate value/unit/label/description/delta/trend parts in that order. Numeric zero is visible; scalar values keep the scalar path. Every provided field retains its original string or number in `sources[].value`. Source ranges address `String(value)` using UTF-16 offsets; the original spelling of a numeric JSON token is not available. No locale formatting, trend icon, case conversion or separator is invented. Empty optional strings retain source mappings with `visible: false`; the required empty value keeps a targetable blank line.\n\nThe allocator tries an adjacent value/unit baseline when both fit one line and the unit uses at most 35% of the cell width; otherwise it stacks the fields. Metadata has an eight-reference-pixel gap, with twelve pixels after the primary row. Related fields stay together rather than being separated by a percentage of the cell height. The value starts at up to 76 reference pixels (28% of cell height); label/unit/delta start at 23, description at 20 and trend at 18. Every requested size is raised to the chosen floor, scaled once. Natural metadata height gets space before reducing type. At most 48 arrangements are evaluated, each with at most 77 value-size trials; identical inputs and a deterministic measurement provider select the fitting candidate with least summed font reduction, preferring the first candidate on ties.\n\nEach part exposes requested/resolved styles and the same source-preserving line/segment representation used by code (`CodeTextFit`), measured with proportional heading/body fonts. CR/LF/CRLF, tabs, whitespace and grapheme boundaries remain exact. Consumers must reuse accepted line and segment positions, font sizes and styles rather than independently re-fit or normalize text. This API reports `provided` measurement when a provider is passed, without claiming that its glyph coverage or shaping is complete. Unsupported glyphs propagate the provider's error with the field path.\n\nPass `align: 'left' | 'center' | 'right'` (default left) to the primitive. Its returned `alignment` and per-part `linePositions` give an absolute x origin and baseline for each `fit.sourceLines` entry, including blank lines. An inline value/unit pair moves together, with the gap following the actual value advance. Composition accepts host-resolved `contentAlignment`; an explicit slide `design.contentAlignment` overrides it. Renderer/export/pagination pass the effective design into the same operation. Alignment does not trigger a second font fit.\n\nCheck `overflow` before consuming parts. Irreducible text, invalid available space, parts outside the cell and overlapping occupied line boxes return field-specific diagnostics; strict mode throws `OPFCompositionError`. Invalid available boxes retain their dimensions and have no fit. These are advance-based line rectangles, not glyph outlines: the controlled browser evidence separately records small glyph overhangs. The API is a bounded internal allocator, not the complete layout-repair/Auto arrange operation or a native export fidelity guarantee.\n\nCurrent `grid-score-v9` retains the metric scoring introduced by `grid-score-v4`: every metric part contributes font reduction, with one overflow penalty per failing metric leaf. `item.metricLayout` is measured against the rounded accepted cell; `item.text`/`item.textStyle` alias the value fit/style. Field diagnostics obey strict ancestor policies, while explicit modes/weights/regions remain authoritative. Metrics are excluded from the advance-model `unmeasuredPayloads` list. Explicit pagination retains metrics atomically with complete source types/metadata and rejects irreducible fields without returning partial output. The published train has [installed-package acceptance](evidence/shipped-train-20260921/installed/acceptance-summary.json), including metric provenance controls. That evidence does not establish native raster or font equivalence.\n\n## Code internals (core 0.9.0 and coordinated packages)\n\nCore 0.9.0 introduced `layoutCode(value, box, options)` for the schema's string shorthand or `{source, language?, filename?}` object. It returns measured filename/language/body parts with exact original text, requested/resolved styles, readability floors, available boxes and diagnostics. Its original integration targeted renderer/PPTX 0.7.0 and editor 0.6.0; the [release checkpoint](plans/shared-code-release.md) records that rollout. These contracts are published in the current core 0.11.0, renderer 0.9.0, PPTX 0.9.1 and editor 0.8.0 train. Core 0.8.0 predates this API and retains the [recorded code-label, filename and whitespace defects](plans/layout-repair.md).\n\n`grid-score-v3` charges code font reductions across all metadata/body parts and one overflow penalty per failing leaf. It preserves explicit modes, weights, regions and source order. Accepted `item.codeLayout` is fitted to the same rounded cell exposed as `item.box`; `item.text` and `item.textStyle` alias the body, not the first metadata part. Strict ancestor settings apply to internal `.source`, `.filename` and `.language` diagnostics. Code no longer appears in `explanation.unmeasuredPayloads`, which concerns the core advance-based model only. It does not mean browser/native fidelity is verified.\n\nPagination slices the code body at grapheme boundaries, repeats filename/language and returns contiguous UTF-16 body ranges while preserving all source bytes and the evaluated readability floor. Irreducible metadata rejects all output, including when the body is empty or earlier content could have fitted. Consumers must preview/export the returned document. The [integration checkpoint](plans/shared-code-integration.md) separates source, installed browser, Windows PowerPoint and remaining release gates.\n\nEach fitted part retains every space and explicit CR/LF/CRLF break. `fit.lines` contains exact source slices, and `fit.sourceLines` records half-open UTF-16 `start`, `end` and `nextStart` offsets, the measured width and a `soft`, `hard` or `end` boundary. A hard break occupies `[end, nextStart)`; soft wrapping consumes no source character. Joining `part.text.slice(line.start, line.nextStart)` reconstructs the original part. Blank lines and a final empty line are retained, and long tokens split only at grapheme boundaries. Filename and language text are not case-converted. An absent/empty metadata pair creates a generated `code` label with no source range.\n\n`code-flow-v1` uses 18-reference-pixel outer insets, an eight-pixel gap between filename and language, and a twelve-pixel gap before the body. Nominal metadata/body sizes are 14/18 reference pixels, raised when necessary to respect the selected minimum, then scaled once. At most four metadata nominal/floor combinations are tried; each body fit tries at most 19 sizes regardless of canvas scale. The fitting combination with least font reduction wins. Irreducible metadata/body failures retain all text and diagnostic paths; invalid available boxes have no fit. `overflow: 'error'` rejects rather than returning partial output.\n\nTabs remain literal characters in part text and displayed-line slices. Measurement advances to the next multiple of four measured spaces from that line's origin; `fit.tabSize` and `fit.tabWidth` expose the rule. Each source line's `segments` contains exact text/tab source ranges plus measured `x`/`width` values relative to its origin. Consumers must reuse those positions: an Edge probe showed that SVG treats a tab as one space despite CSS `tab-size: 4`. The published SVG renderer uses positioned spans and geometric precision; native export uses accepted tab stops. Width measurements and source preservation alone do not establish glyph-outline containment, shaping/bidi support or native fidelity. [Installed workflow evidence](evidence/shared-code-installed/summary.json) records the separate actual browser and native checks with their exact font/runtime scope.\n\nPublished native export stores source boundaries in standard PowerPoint shape tags. Complete unique groups recover exact code/source metadata, with current native text taking precedence. Missing, damaged or ambiguous groups retain visible native shapes and report diagnostics. Reimport does not reconstruct native formatting, positioning, font theme or readability policy. Eight installed-export wide/portrait slides pass native edit/save/reopen and all 24 original/saved/edited imports on the recorded Windows PowerPoint build; this is not arbitrary PowerPoint round-trip or pixel equivalence. The editor preserves untouched CRLF/CR source around edits and keeps committed preview geometry separate from its active native textarea caret.\n\nThe JSON schema can accept strings that [XML 1.0 cannot represent](https://www.w3.org/TR/xml/#charsets). Published SVG/PPTX code output rejects forbidden controls, unpaired UTF-16 surrogates, U+FFFE and U+FFFF with `invalid-code-text`, the source field path and UTF-16 offset in the message. The input stays unchanged; the caller can correct that character explicitly. Tabs, CR/LF/CRLF and valid supplementary characters remain accepted for serialization. Schema support, format representability and glyph coverage are separate properties.\n\nThe controlled SVG harness requests `text-rendering=\"geometricPrecision\"` as well as explicit segment placement. Initial Linux Chromium CI rounded glyph advances under default hinting, unlike Windows Edge with the same font bytes. The [SVG specification](https://www.w3.org/TR/SVG/painting.html#TextRenderingProperty) defines geometric precision as a rendering hint, so consumers still need actual browser checks with their exact fonts and supported environments; the hint alone does not certify agreement. The harness retains a 0.1-reference-pixel tolerance and records observations before assertions.\n\n## Quote internals (core 0.8.0 and coordinated packages)\n\nCore 0.8.0 exports `layoutQuote(value, box, options)` from the `@openpresentation/opf/composition` entrypoint. Pass validated quote content (object or string shorthand), its allocated reference-pixel box, resolved `fontFamilies`, `textMeasurement`, `scale` (canvas short edge / 720), effective `minFontSize`, `overflow` policy and its source `path`.\n\nThe result contains `parts` for the body and any nonempty footer, exact display `text`, source mappings, requested and resolved text styles, and the available boxes/fits. Source ranges use half-open UTF-16 offsets in both the source field and display string; generated quotation marks and the footer separator have no source range. The original content is never modified. A supplied width provider is reported as `provided`; it does not certify shaping or font fidelity.\n\nCheck `overflow` and `diagnostics` before accepting the parts. Invalid available dimensions remain visible with `fit` absent, and `overflow: 'error'` throws `OPFCompositionError`. Diagnostics distinguish invalid part space, parts outside their cell, text that exceeds its reserved space, and overlapping line rectangles. Those rectangles are conservative text-layout bounds, not measured glyph outlines. The readability floor is scaled once and can raise the nominal body (28) or footer (17) size; it is never silently capped below the selected floor.\n\n`quote-flow-v1` keeps 18-reference-pixel outer insets and an 18-pixel body/footer gap while fonts scale with the canvas. A 40-pixel footer is a whitespace preference. The allocator expands it for long sources or compacts it for dense bodies, trying at most the nominal and minimum footer sizes and selecting the fitting pair with least total font reduction. If neither fits, it returns floor-size failure diagnostics. This is a bounded internal allocation step, not a complete layout-repair engine.\n\n`composeSlide` scores both parts and accepts geometry against the final rounded item box. Each quote item carries `quoteLayout`; its compatibility `text` field is the same fit object as the quote body, including generated quotation marks. Consumers needing original offsets must use the explicit `sources` mappings. The coordinated renderer and PPTX consume these parts without another measurement/style-resolution pass. Missing geometry or invalid part boxes reject rendering/export rather than omitting content. This requires core 0.8.0 with renderer/PPTX 0.6.0; older core 0.7.0/renderer 0.5.1/PPTX 0.5.2 lack these changes. The complete published set, immutable verification refs and fresh registry evidence are recorded in `release-plan.json` and [the release plan](plans/shared-quote-release.md).\n\nBrowser glyph bounds can extend slightly beyond advance-based part boxes into the reserved inset. Current loaded-font tests record those overhangs, verify glyph containment inside the full quote cell and check body/footer separation. Native PowerPoint fixtures separately verify text, sizes, cell containment, save/reopen and reimport. Neither test establishes universal pixel equivalence. Original requested-font provenance through host substitutions and non-quote payload internals remain open requirements.\n\n### Quote role and photo (FA-12)\n\n`QuoteContent` also takes `role` and `photo`. The footer text is the attribution, then `role` after a line break, then `source` after ` - `; each field keeps its own source mapping (`<path>.role`). Without a `role` the footer text is unchanged. A `photo` (an `Asset` value) adds `QuoteLayout.photo`: `{path, value, box, shape}`, where `shape` is the circle mask of `imageShape('circle', box)` (DrawingML `ellipse` and the same outline as an SVG path). The diameter is three times the footer font size and the gap beside it three quarters of that size, so the allocator picks the footer size (nominal or the readability floor) with the photo in the footer height and the footer text in the remaining width. The photo sits at the start edge of the inset area: the left, or the right when `options.direction` is `rtl`, where the footer text is its mirror. The text block is centered on the photo. A photo whose height cannot fit at the floor adds the `photo-fit` diagnostic reason (and `overflow`); a photo with no footer text still draws. Without a photo `layoutQuote` returns exactly what it returned before, with no `photo` key. The renderer and the PPTX exporter draw `photo` from the result and do not recompute it.\n\n## Timeline internals\n\n`layoutTimeline(value, box, options)` (`timeline-flow-v1`) places an optional name and description, then the events as a marker on a connector with each event's `when`, `what` and `description` as separate text parts. It tries an alternating arrangement (labels above and below a horizontal connector) and a vertical one (a marker rail down the start edge, labels beside it), at most 50 candidates in all, and keeps the first that fits or the one with the fewest diagnostics. Right-to-left decks put the vertical rail on the right edge.\n\n### Event status (FA-11)\n\n`TimelineEvent.status` is `done`, `current` or `planned`. It is a progress state that the engines draw from the deck's own colors without judging it; schedule health (at risk, blocked) is deliberately not a status. Layout copies the status to `TimelineLayout.markers[i].status` and to each of that event's text parts (`TimelineTextPart.status`), and `timelineMarkerShapes(marker, colors)` and `timelineTextColor(part, colors)` turn it into the shapes and text color that both engines draw:\n\n| Status | Marker | Text |\n| --- | --- | --- |\n| absent | A filled circle in the primary color. | Normal text color. |\n| `done` | The same filled circle, so it looks like an event without a status. | Normal text color. |\n| `current` (\"we are here\") | The filled circle inside a ring: a circle 1.6 times the marker's radius with a primary-color outline over the slide background. | The label (`what`) is bold, in the normal text color. |\n| `planned` | A hollow-looking circle of the marker's radius: a primary-color outline filled with the slide background. | All of the event's text is the muted secondary text color. |\n\n- **Geometry.** A marker's `radius` is the radius of its ellipse, for every status. A `current` marker also has `ring.radius`, exactly 1.6 times `radius`, and a `current` or `planned` marker has the `strokeWidth` of its outline (2 reference pixels at 720, at most half the radius). The drawn and exported ellipses have exactly these radii, so every ellipse of a deck has the same size for the same radius (a done, planned and unset marker are the same size); an outline is centered on its ellipse's edge and reaches half a stroke beyond it, and the cell check and the vertical rail count that half stroke. A ring that leaves the cell is reported as a `event-space` diagnostic like any marker. In the vertical arrangement a deck with a `current` event moves the rail and the text out by the ring's extra width so the text keeps its usual distance from the marker. Events without a status produce exactly the geometry and no extra keys they did before.\n- **Hollow means background-filled.** The outline of a hollow marker and the ring are drawn over the slide background color (in the SVG as the fill, in PPTX as a solid fill of the same color) so the connector does not cross them.\n- **Colors.** `timelineTextColor` returns `mutedText` for a planned event, lightened or darkened until it reaches 4.5:1 against the slide background (`TIMELINE_TEXT_MIN_CONTRAST`), and the normal text color otherwise. Outlines and rings are kept at 3:1 (`TIMELINE_OUTLINE_MIN_CONTRAST`); the filled marker keeps the primary color exactly.\n- **Bold label.** The weight is part of the layout, not a drawing detail: the `what` part of a `current` event is requested at weight 700, so line breaks and heights were measured bold.\n- **PPTX.** The marker is a native `ellipse` (solid primary fill for `done`, `current` and no status; background fill plus a primary line for `planned`), the ring is a second native `ellipse` (background fill plus a primary line), the bold label is a bold run, and planned text takes the muted color. The exporter records each event's status in the marker's `OPF_TIMELINE_V1` tag and the ring in a tag of its own, and the importer restores `status` from them; a deck whose marker or ring was edited away imports as ordinary text without status, like the rest of the timeline provenance. Status is also written in Markdown as a task-list prefix on the event line: `[x]` done, `[>]` current, `[ ]` planned.\n\n## Resizing in the preview\n\nChoose **Arrange** in the editor to reveal track dividers. Drag a divider to redistribute the space between adjacent columns (row/grid) or rows (column), including nested groups. Arrow keys make small changes; Shift makes larger changes. Escape discards a pointer draft. One drag creates one undo step, and no content is removed. Strict overflow rejects a resize that violates its fit constraints.\n\nResizing an automatic layout makes its chosen columns explicit as `mode: grid` with `columns`. This prevents the number of columns from changing under the pointer. The adjacent share clamps to 5\u201395%, with positive schema-valid weights. Other track proportions and unrelated document fields remain intact. Promoted regions retain their positions; their nested groups can still be resized. Layouts with reserved placeholder slots need an explicit arrangement first. Flows with more than twelve tracks need grouping before the current resize controls can express all weights.\n\n`createCanvasEditor(container, {layoutEditing: true, ...options})` enables dividers initially. `canvas.setLayoutEditing(boolean)` toggles them, and `canvas.commit()` / `canvas.cancel()` also handle an active resize. `onDraft` receives the proposed document; the session stays unchanged until commit. Changes to the resized container cancel a stale draft; unrelated updates are retained.\n\nThe shared engine exposes `geometry.flows`: each flow has its container path, content box, resolved column/row tracks (offset and size), clamped gap, effective composition, item count, and reserved slot count. This is renderer geometry, not new OPF document fields.\n\nAgents can prepare the same guarded change without a DOM:\n\n```js\nimport {prepareTrackResize} from '@openpresentation/opf-editor/layout';\nimport {resolvePresentation} from '@openpresentation/opf-render/svg';\nconst geometry = resolvePresentation(editor.presentation, { fonts }).slides[0].geometry;\nconst flow = geometry.flows.find(flow => flow.path === 'slides.0');\nconst prepared = prepareTrackResize(editor.presentation, flow, 0, 0.65);\n// Boundary 0: give the first track 65% of the adjacent pair's combined space.\n// Preview prepared.presentation with the same renderer and fonts handle before applying.\neditor.applyPatch(prepared.patches, {rejectInvalid: true});\n```\n\nThe patch contains a `test` guard for the container before changing its composition. Failed tests do not mutate the document or its history. A test-only patch is read-only. Rendering is preflighted by the canvas; headless callers should likewise render a candidate to enforce font and overflow constraints.\n\nVerification: editor layout model tests, `/layout-tests.html` browser keyboard checks and trusted-pointer specimens, and `pnpm test:layout` for measured SVG/native PPTX coordinate parity. Shape-coordinate checks do not establish PowerPoint raster pixel parity.\n\n\n## Reordering and moving blocks\n\nIn **Arrange**, drag a numbered block handle to reorder siblings. The insertion marker shows the destination; the shared renderer reflows the slide after drop. Arrow keys on a handle move the whole block earlier or later. Click a handle for **Earlier**, **Later**, or an explicit destination and insertion position. The destination menu supports existing groups and block-based slides, including moving a child out of a group or moving a whole group to another slide. `canvas.openBlockMenu(path)` opens the same controls programmatically.\n\nA move preserves the entire block and its nested content, formatting, data, and references. Parent composition weights describe positions, so they stay in place. Moving to another container can change the block's inherited design and readability constraints; the canvas renders the candidate before committing it. Strict overflow or an unavailable required font rejects the move. A move cannot leave an empty block container or put a group inside its own descendants. Move the group or add another block first when the source has only one child.\n\n```js\nimport {prepareBlockMove, listBlockContainers} from '@openpresentation/opf-editor/layout';\nconst containers = listBlockContainers(editor.presentation);\nconst prepared = prepareBlockMove(editor.presentation,\n '/slides/0/blocks/0', '/slides/0/blocks/1', 1);\n// Insert the first block before child 1 of the second block's group.\n// Destination indexes refer to the document before removal.\n// prepared.path reports the moved block's address after any index shifts.\neditor.applyPatch(prepared.patches, {rejectInvalid: true});\n```\n\n`prepareBlockMove` returns `{presentation, patches, path, changed}`. It validates the complete result and emits guarded remove/add patches, so the editor or CLI can apply it atomically. No-op moves return `changed: false` and no patches. `listBlockContainers(presentation, {slideIndex})` optionally limits discovery to a single slide and excludes arbitrary extension data. Headless callers should render the candidate with their intended font provider before applying. The browser and installed-package block harnesses exercise nested moves, undo, stale menus, keyboard access, strict-fit rejection, and native drag reordering.\n\nCreation and deletion use the same layout engine: insertions can normalize implicit payloads into explicit blocks; deletions prune empty groups while retaining the slide. Existing track weights stay positional. See the [editor creation guide](live-editor.md#create-duplicate-and-delete-content) for the guarded APIs and canvas controls.\n"
80
80
  },
81
81
  {
82
82
  "slug": "ecosystem-development",
83
83
  "file": "docs/ecosystem-development.md",
84
84
  "title": "Local ecosystem development",
85
- "markdown": "# Local ecosystem development\n\nUse Node 24 for the current source and published packages. Keep `opf`, `opf-render`, `opf-pptx`, `opf-editor`, and `pptx-gallery` in the same parent directory. Install each repository's dependencies normally, then run these commands from `opf`:\n\n```sh\npnpm build\nnode scripts/link-ecosystem.mjs\npnpm test:ecosystem\npnpm test:gallery\n```\n\nThe link command replaces the installed `@openpresentation/opf` package in sibling `node_modules` with a link to this checkout and builds the toolkit packages. It also links the renderer into editor/converter consumers and the converter into the editor. It does not save machine-specific paths in package manifests or lockfiles. Reinstalling dependencies can replace the links; rerun the command afterwards. Use `--packages-only` to omit the gallery checkout.\n\nOn Windows, directory junctions work without granting file-symlink privileges. The linker refuses a package parent that resolves outside the sibling checkout's `node_modules`, and replaces existing links without following them into source. npm/pnpm orchestration invokes the package manager's JavaScript entrypoint with the selected Node runtime instead of running a batch shim through a shell. Paths with spaces and shell metacharacters remain literal arguments. The supported npm-installed and npm-exec package-manager layouts are discovered from `PATH` or the matching `npm_execpath`; a missing manager returns an explicit installation error.\n\nThe core packed-install smoke check also uses this Windows invocation. The following portability results record the historical September 9 integration, before the current Node 24 requirement; current acceptance is linked from the [compatibility matrix](compatibility-matrix.md). Node 20/24 local evidence on the `codex/windows-test-harness-20260909` branch: all 414 core tests plus composition/pagination/data/rich-text/list suites pass, and actual local tarballs install into fresh temporary projects and pass 519 packed-entry checks. New isolated tests execute real npm builds, replace existing junctions, retain literal arguments, and reject an external `node_modules` parent without modifying its package. The then-current Windows/macOS CI repeated the core packed installation on both runtimes. These are local unpublished tarballs, not republished core 0.7.0 or proof of native rendering fidelity.\n\nAfter integrating reviewed layout PR #43, the combined source passes all 420 core tests on local Windows Node 24. Exact combined-source CI and review are recorded on PR #44.\n\nCoordinated CI `34384776504` and `34385059710` caught an older isolated-link fixture copying the linker without its new helper, causing `ERR_MODULE_NOT_FOUND` before package tests ran. The fixture now copies both files, passes directly on Windows Node 20/24, and runs in the Windows/macOS matrix as well as coordinated CI. This failure was fixed rather than waived; renewed combined-source CI was required at that checkpoint.\n\nThe current published compatible set is core 0.13.0, CLI 0.11.0, renderer 0.13.1, PPTX 0.13.2 and editor 0.12.1 on Node 24. Clean registry installs include shared composition and styled table rows without sibling links. `release-plan.json` records exact versions and immutable verification sources; `pnpm test:registry-ecosystem` and `pnpm test:registry-fidelity` exercise those installed packages. Source links are for coordinated development.\n\nExecute the installed-package browser harnesses after their corresponding build:\n\n```sh\npnpm test:packages\npnpm test:packed-browser\npnpm test:registry-ecosystem\npnpm test:packed-browser registry\n```\n\nThe renderer checkout supplies its locked Playwright test dependency. Install Chromium with `npm exec --prefix ../opf-render -- playwright install --with-deps chromium` on Linux. Local Windows runs use Edge; `OPF_BROWSER_CHANNEL` can explicitly select another installed Playwright channel. CI uses the matching official Playwright container pinned by digest, without installing OS packages during each run.\n\nEach build writes `artifacts/editor/packed-browser-manifest.json` with the mode, installed versions, consumer build ID, dependency-lock hash and exact font/HTML/JavaScript hashes. The runner rejects a different mode, stale consumer or changed asset. It serves only the verified bytes on loopback and rejects external requests and network writes. Rebuild before switching between candidate and registry modes. Reports include the browser and Node versions and are saved by mode/runtime; failures retain a screenshot.\n\n`node scripts/test-packed-browser-guards.mjs` verifies those four rejection cases against the current disposable harness and restores each changed fixture byte-for-byte. CI runs it after the registry browser checks.\n\nSeven suites exercise canvas, rich text, lists, creation, layout, block moves and styled tables. Real browser input covers divider resizing/cancellation/concurrent changes, block dragging, merged-cell typing/redo/undo, plain-to-rich conversion and bold formatting, and empty-cell typing/undo. Conversion and formatting currently create separate undo transactions. Harness DOM assertions also cover renderer agreement and preservation. These checks do not replace full application export/reimport, public deployment checks or native PowerPoint raster evidence.\n\nTo browse the gallery with the linked package:\n\n```sh\ncd ../pptx-gallery\nOPF_LOCAL_WORKSPACE=1 pnpm dev\n```\n\nLayout detail pages have an interactive composition example. The flag expands Turbopack's local root to include the sibling package; production builds use the gallery root.\n\n`pnpm test:ecosystem` validates the dynamic composition fixture, edits and undoes a composition, renders SVG/PNG/PDF, exports editable PPTX, checks OOXML text-box coordinates against the shared geometry, and imports the result back into schema-valid OPF. Artifacts are written to a temporary directory and its location is printed.\n\nFor tests that should read current source without modifying installed packages, use Node's local loader after building OPF:\n\n```sh\nnode --import ./scripts/register-local-opf.mjs ../opf-render/test/smoke.mjs\n```\n\nThe loader redirects only `@openpresentation/opf` imports to this checkout. Ordinary dependencies still resolve from the consuming repository.\n\nFor full gallery render coverage, run `pnpm test:gallery -- --render` (or invoke the script with `--render`). The test validates all 854 generated documents and can render them with the local SVG engine.\n\nBuild OPF before starting a linked gallery. Stop and restart the gallery around clean OPF rebuilds; removing the linked `dist` directory during compilation can leave Turbopack with stale missing-module errors.\n\n`pnpm test:pagination` verifies long-text and table pagination through SVG and editable PPTX, including exact source reconstruction, table row counts, and absence of extra exporter-created pages. It writes review artifacts under `artifacts/pagination/`.\n\n`pnpm test:fonts` verifies actual-font measurement across editor, SVG, pagination, and PPTX. It also runs the offline font-switch matrix (`scripts/test-font-switch-ecosystem.mjs`, FF-09): a seeded pairwise covering array of 58 decks over the 14 gallery dimensions, plus fixed content-type, block-replacement, per-slide override, CJK-in-Latin, theme and language switches, each switched A to B and back to A. A value class is a group of catalog values that take the same path through the engines, derived from the catalogs in the script: font schemes by language family, then by licensing and preview policy (Office metric, Office visual-only, monospace, open Google); one language per script family in the array and every other catalog script in a language chain; every layout family; the eight content blocks; every distinct chart export path of the non-deprecated chart types; header/footer, background (theme, solid, gradient, pattern, image) and slide-image treatments by kind; and the first and last record of the metadata dimensions. Dimensions that a deck can carry several times (font scheme states, layouts, blocks, charts, backgrounds, images) take several values per deck. Every state is exported and checked with the FF-08 typeface inventory, the catalog's literal theme fonts, a package structure check, a preview re-render and a re-import. It pins the office font pack with visual substitution and asserts every substitution; known engine limitations, including chart types the preview approximates or the exporter writes as bar charts, are named expected failures in the script that fail with a \"limitation resolved\" message when they go away. It runs no browser and no Office. Its report is written to `artifacts/font-switch-matrix/report.json`. The pairwise decks run on four worker threads with a fixed partition (deck i on worker i mod 4), so every host verifies each deck after the same earlier decks; `OPF_MATRIX_WORKERS=1` runs them in one thread, `OPF_MATRIX_WORKERS=n` on n workers. See [font fidelity](font-fidelity.md) for loading and embedding local fonts and for current native PowerPoint limits.\n\n## Coordinated CI: the ecosystem lock\n\n`ecosystem.lock.json` records the four OpenPresentation commits (`opf`, `opf-render`, `opf-pptx`, `opf-editor`) that passed the coordinated ecosystem checks together, and the golden baseline (`OPF_GOLDEN_BASELINE`) the locked renderer renders the core examples against. `scripts/ecosystem-lock.schema.json` is its schema and `node scripts/ecosystem-lock.mjs validate` checks it with the same rules.\n\n- **Who writes it.** The SHAs are written by the roller, never by hand. A pull request that moves goldens may change `golden` (a reviewed golden decision, like the `OPF_GOLDEN_BASELINE` edits it replaces).\n- **Guard.** `node scripts/ecosystem-lock.mjs guard` checks every locked SHA against its repository's `main` through the GitHub REST compare API (the equivalent of `git merge-base --is-ancestor <sha> main`, no clone), and flags a pull request that changes a locked SHA from a branch other than the roller's (`ecosystem-roll/*`). The `ecosystem-core` job runs it as a warning; the repository variable `ECOSYSTEM_LOCK_GUARD=blocking` (or `--blocking`) makes a finding fail the job.\n- **Reading it.** CI never pins a sibling by hand. Each job runs the composite action `.github/actions/ecosystem-refs` with its own repository as `consumer`; the action reads `ecosystem.lock.json` from its own commit and returns the commit to check out for each repository (`opf`, `opf_render`, `opf_pptx`, `opf_editor`) and the golden baseline relative to the workspace (`golden`, for example `opf/scripts/fixtures/opf-examples-png.audience-ids.sha256.json`). Core's workflows use `./opf/.github/actions/ecosystem-refs` (the lock of the commit under test); the sibling repositories use `OpenPresentation/opf/.github/actions/ecosystem-refs@main` (the lock on core `main`). `export-golden: 'true'` exports `OPF_GOLDEN_BASELINE`; `golden-override` replaces the lock's golden with a workspace-relative baseline (a renderer pull request that moves pixels). Locally: `node scripts/ecosystem-lock.mjs resolve --consumer opf`.\n- **Depends-On.** For a change that needs an unmerged pull request of another ecosystem repository, add a line to the pull request body, for example `Depends-On: OpenPresentation/opf#264` (several pull requests may be listed, comma-separated or on several lines; the `https://github.com/OpenPresentation/<repository>/pull/<number>` form works too). On `pull_request` and `merge_group` events the action then checks that repository out at the named pull request instead of the lock: its test merge commit (`refs/pull/<n>/merge`) while it is open and mergeable, its head while it has conflicts, and its merge commit once it is merged (so a dependent pull request needs no edit after its dependency merges; re-run its checks). A closed, unmerged dependency fails the step; another organization's repository, a repository outside the four and a dependency on the pull request's own repository are reported and ignored; trailers inside fenced code blocks do not count. The body is read through the REST API, so after editing it re-run the checks. In the merge queue the pull request is the head pull request of the queue branch (`gh-readonly-queue/main/pr-<n>-<sha>`), so a breaking coordinated change is checked against its open sibling pull requests there too. In a batch of more than one pull request only that head pull request's `Depends-On` applies, so queue a coordinated change alone. `push` and scheduled runs always use the lock, so main is red for the sibling suites until the dependencies merge and the roll moves the lock. This replaces throwaway pin branches and repin pull requests. Check a body locally with `node scripts/ecosystem-lock.mjs depends-on --body-file body.md`. A renderer dependency that moves pixels brings its own golden: when opf-render comes from a `Depends-On` pull request and the step has no `golden-override` of its own, the action adopts the `golden-override` of that pull request's `.github/workflows/ci.yml` (the roller's rule: an `opf-render/test/golden/<name>` baseline or an `opf/scripts/fixtures/<name>.sha256.json` fixture, which must exist at the commit that is checked out) and logs `golden ... adopted from Depends-On OpenPresentation/opf-render#<n>`. `merge_group` runs keep the lock's golden, so the lock must still describe the locked renderer (FA-19).\n- **Roller.** `scripts/ecosystem-roll.mjs` (workflow \"Ecosystem lock roller\", `.github/workflows/ecosystem-roll.yml`) tries the four `main` branches together: it builds the candidate lock (the four `main` SHAs; the lock's golden, or opf-render main's `golden-override` when it sets one), force-moves `ecosystem-roll/main` to core `main`, commits the candidate there, and proposes it as a pull request only when the coordinated checks pass. It never writes `main`. `node scripts/ecosystem-roll.mjs plan --lock ecosystem.lock.json` shows the candidate without writing.\n - **Without the GitHub App (today).** The roller runs with `GITHUB_TOKEN`, whose pushes start no workflow and whose pull requests start no checks. So it dispatches `Coordinated public packages` and `OPF CI` on its branch (a `workflow_dispatch` made with `GITHUB_TOKEN` does start a run), waits for them, and proposes only a green candidate; those runs report the required checks on the branch head, which is the pull request head. The repository does not let GitHub Actions open pull requests (\"Allow GitHub Actions to create and approve pull requests\" is off), so a green roll ends with a compare link in the job summary and a maintainer opens the pull request. The workflow is dispatch-only: the hourly schedule stays commented out.\n - **With the App ([opf#298](https://github.com/OpenPresentation/opf/issues/298)).** Set the repository variable `ECOSYSTEM_APP_ID` and the secret `ECOSYSTEM_APP_PRIVATE_KEY`: the workflow's token step then runs and the roller uses the App token with no code change, opening the pull request itself (its normal checks decide). Then uncomment the schedule, and let each sibling send a `repository_dispatch` of type `ecosystem-main-updated` after a merge to `main`.\n- **No hand pins.** `scripts/ecosystem-lock.test.mjs` fails when a core workflow checks out an OpenPresentation repository at a hand-written SHA or selects a hand-written `OPF_GOLDEN_BASELINE`. The lock was generated from the last hand pins of `.github/workflows/ecosystem-ci.yml`; see [the CI study](programs/release-readiness/ci-cd.md), section 3.\n\n## Regenerating core's golden fixture\n\nA pull request that changes `examples/`, or the core code that composes them, moves the corpus digest and slide hashes in the fixture `ecosystem.lock.json` `golden` selects (`scripts/fixtures/opf-examples-png.*.sha256.json`; `scripts/fixtures/README.md` keeps the review record). opf-render's `regenerate-goldens` reads only the lock, so it cannot see an unmerged core pull request. The `regenerate-core-golden` workflow (`.github/workflows/regenerate-core-golden.yml`, helpers in `scripts/core-golden.mjs`, tests in `pnpm check:core-golden`) does it for a branch of this repository, with the method of RR-20 (opf#367): the locked renderer's `test/golden.mjs`, core linked, scale 0.25, `systemFonts: false`, on Linux in the Playwright image `ecosystem-ci.yml` pins, Node 24.\n\n```sh\ngh workflow run regenerate-core-golden.yml -f branch=<branch> # render, push one fixture commit, start CI\ngh workflow run regenerate-core-golden.yml -f branch=<branch> -f dry-run=true # render and report only\n```\n\n- **Inputs.** `branch` (required): a branch of this repository, never the default branch; a fork (`owner:branch`), a ref, a SHA or any other name is refused, and the branch is checked out from this repository only. `dry-run` (default false) renders and writes the report without pushing. `dispatch-ci` (default true) starts `OPF CI` and `Coordinated public packages` on the branch after the push (a push with `GITHUB_TOKEN` starts no workflow). Dispatch it on `main`: a `workflow_dispatch` sees only workflows on the default branch, and the run refuses any other ref.\n- **Step one, the reproducibility guard.** It renders main's examples with main's core and the locked renderer (the renderer commit in `ecosystem.lock.json`) and requires the generated fixture to be byte-identical to main's committed one. If it is not, the run fails and pushes nothing: the environment cannot be trusted to write a fixture (or main's fixture is stale), and the run log lists the slides that differ.\n- **Step two.** It renders the branch head the same way. The fixture is written to the path the branch's `ecosystem.lock.json` selects; the commit may touch only that one `scripts/fixtures/<name>.sha256.json` (any other change fails the run), its message lists every changed slide (deck, index, old and new hash) compared with main's fixture, and it is pushed to the branch without force by `github-actions[bot]`. The push is a second job that holds the write token and runs only scripts from `main`; the branch's code (install, build, render) runs in a job with read access only. A branch that moved while rendering, a branch whose lock locks another renderer than main (rebase), and a branch that lacks a change on `main` to `examples/`, `packages/`, `spec/`, `scripts/fixtures/`, the lock or the lockfile (rebase) fail without pushing. A fixture that already equals the render, or `dry-run`, pushes nothing.\n- **Job summary and artifact.** The summary lists the changed slides per deck, ready to paste into the pull request body (add the reason for each change), and every old and new hash; the `core-golden-report` artifact holds the same (`changes.json`, `summary.md`, `commit-message.txt`, the fixture) and the review sheets and PNGs of the slides that differ from main's fixture. Look at them before approving: the commit is evidence, not an approval.\n- **Merge rhythm.** Merges are sequential and each examples-touching merge changes main's fixture. After every such merge, the next examples-touching pull request rebases onto main and dispatches this workflow, then merges once CI on the new commit is green. Edit the pull request body with the list before merging.\n- **CI after the push.** A dispatched run reads `ecosystem.lock.json` only, so for a pull request whose body has `Depends-On:` lines close and reopen it (or push) to run its own `pull_request` checks.\n- **Limits.** The renderer is the locked one; a pull request that also needs an unmerged opf-render change cannot be regenerated here. A fixture in another repository (a lock `golden` that names opf-render) is refused.\n\n## Coordinated CI: the contract tier\n\n`Coordinated public packages` (`.github/workflows/ecosystem-ci.yml`) runs the three siblings' suites in two depths ([the CI study](programs/release-readiness/ci-cd.md), section 3, consumer-driven contracts). `scripts/ecosystem-scope.mjs` (`tierForEvent`) decides the depth and every sibling shard passes it as `--tier` to `scripts/test-package-ecosystem.mjs` (`scripts/package-ecosystem-plan.mjs` lists the commands).\n\n| Event | Depth | What a sibling runs |\n|---|---|---|\n| `pull_request` | `contract` | `npm run test:contract` (instead of `npm run test`) |\n| `merge_group`, the nightly `schedule` (01:37 UTC), `workflow_dispatch` | `full` | `npm run test` |\n| `pull_request` that changes `ecosystem.lock.json`, the workflow, the tiering scripts or the `ecosystem-refs` action, comes from an `ecosystem-roll/*` branch or has the label `ecosystem-full` or `full-ci` | `full` | `npm run test` |\n\n- **The contract suite.** Each sibling defines it in `test/suites.json`: `contractExclude` names the tests of `npm test` (`scripts/run-tests.mjs`) that the contract leaves out, each with a reason (tests of the package's own machinery that take no input from core). Every other test is in the contract, so a new test gates core pull requests until someone excludes it on purpose. `node scripts/run-tests.mjs --suite contract --list` prints it; the sibling's `check:runners` fails on an exclusion that names a missing file or has no reason. `test:contract` also keeps the cheap build steps that bundle core (the renderer's font licence check and browser bundle, the converter's browser bundle). Nothing else in a shard changes: typecheck, validate, `test:code`, `test:font-preparation`, `test:font-variants`, the browser steps and the golden gate (805 raster hashes, exact) run at both depths, and no tolerance moved.\n- **Nothing is skipped by the tier.** Only a pull request can run `contract`; a docs-only or contract-only change never lowers a `merge_group`, `main` or nightly run (`scripts/ecosystem-scope.test.mjs`). A sibling whose commit has no `test:contract` script (a lock that predates RR-53) runs its full `npm run test` with a warning.\n- **The merge queue and the nightly run (RR-57).** The queue's `merge_group` run is the full gate before main, and main is fast-forwarded to the exact commit it tested, so there is no push-to-main run (it repeated the same SHA; `scripts/release-train.mjs` reads the queue run's check runs on that commit). The nightly run is the safety net for anything that reaches main without the queue. A red nightly run fails the workflow run and the job `main-status` keeps an issue labelled `main-red` open for the red period (a comment for each further red run, closed by the next green one); poll it with `gh api 'repos/OpenPresentation/opf/issues?labels=main-red&state=open'`. Fix forward or revert. Add the label `ecosystem-full` (or `full-ci`) to a pull request that you know reaches past the contract (the font catalogs, the script packs, the PDF path) to run the full suites on it; the label takes effect on the next push or a re-run.\n- **Merge order.** A sibling pull request that adds or changes its contract merges first; the roller then moves the lock. Core's pull request tier reads the contract at the lock's commit, or at the pull request named by a `Depends-On:` line.\n"
85
+ "markdown": "# Local ecosystem development\n\nUse Node 24 for the current source and published packages. Keep `opf`, `opf-render`, `opf-pptx`, `opf-editor`, and `pptx-gallery` in the same parent directory. Install each repository's dependencies normally, then run these commands from `opf`:\n\n```sh\npnpm build\nnode scripts/link-ecosystem.mjs\npnpm test:ecosystem\npnpm test:gallery\n```\n\nThe link command replaces the installed `@openpresentation/opf` package in sibling `node_modules` with a link to this checkout and builds the toolkit packages. It also links the renderer into editor/converter consumers and the converter into the editor. It does not save machine-specific paths in package manifests or lockfiles. Reinstalling dependencies can replace the links; rerun the command afterwards. Use `--packages-only` to omit the gallery checkout.\n\nOn Windows, directory junctions work without granting file-symlink privileges. The linker refuses a package parent that resolves outside the sibling checkout's `node_modules`, and replaces existing links without following them into source. npm/pnpm orchestration invokes the package manager's JavaScript entrypoint with the selected Node runtime instead of running a batch shim through a shell. Paths with spaces and shell metacharacters remain literal arguments. The supported npm-installed and npm-exec package-manager layouts are discovered from `PATH` or the matching `npm_execpath`; a missing manager returns an explicit installation error.\n\nThe core packed-install smoke check also uses this Windows invocation. The following portability results record the historical September 9 integration, before the current Node 24 requirement; current acceptance is linked from the [compatibility matrix](compatibility-matrix.md). Node 20/24 local evidence on the `codex/windows-test-harness-20260909` branch: all 414 core tests plus composition/pagination/data/rich-text/list suites pass, and actual local tarballs install into fresh temporary projects and pass 519 packed-entry checks. New isolated tests execute real npm builds, replace existing junctions, retain literal arguments, and reject an external `node_modules` parent without modifying its package. The then-current Windows/macOS CI repeated the core packed installation on both runtimes. These are local unpublished tarballs, not republished core 0.7.0 or proof of native rendering fidelity.\n\nAfter integrating reviewed layout PR #43, the combined source passes all 420 core tests on local Windows Node 24. Exact combined-source CI and review are recorded on PR #44.\n\nCoordinated CI `34384776504` and `34385059710` caught an older isolated-link fixture copying the linker without its new helper, causing `ERR_MODULE_NOT_FOUND` before package tests ran. The fixture now copies both files, passes directly on Windows Node 20/24, and runs in the Windows/macOS matrix as well as coordinated CI. This failure was fixed rather than waived; renewed combined-source CI was required at that checkpoint.\n\nThe current published compatible set is core 0.14.0, CLI 0.11.0, renderer 0.14.0, PPTX 0.14.0 and editor 0.14.2 on Node 24. Clean registry installs include shared composition and styled table rows without sibling links. `release-plan.json` records exact versions and immutable verification sources; `pnpm test:registry-ecosystem` and `pnpm test:registry-fidelity` exercise those installed packages. Source links are for coordinated development.\n\nExecute the installed-package browser harnesses after their corresponding build:\n\n```sh\npnpm test:packages\npnpm test:packed-browser\npnpm test:registry-ecosystem\npnpm test:packed-browser registry\n```\n\nThe renderer checkout supplies its locked Playwright test dependency. Install Chromium with `npm exec --prefix ../opf-render -- playwright install --with-deps chromium` on Linux. Local Windows runs use Edge; `OPF_BROWSER_CHANNEL` can explicitly select another installed Playwright channel. CI uses the matching official Playwright container pinned by digest, without installing OS packages during each run.\n\nEach build writes `artifacts/editor/packed-browser-manifest.json` with the mode, installed versions, consumer build ID, dependency-lock hash and exact font/HTML/JavaScript hashes. The runner rejects a different mode, stale consumer or changed asset. It serves only the verified bytes on loopback and rejects external requests and network writes. Rebuild before switching between candidate and registry modes. Reports include the browser and Node versions and are saved by mode/runtime; failures retain a screenshot.\n\n`node scripts/test-packed-browser-guards.mjs` verifies those four rejection cases against the current disposable harness and restores each changed fixture byte-for-byte. CI runs it after the registry browser checks.\n\nSeven suites exercise canvas, rich text, lists, creation, layout, block moves and styled tables. Real browser input covers divider resizing/cancellation/concurrent changes, block dragging, merged-cell typing/redo/undo, plain-to-rich conversion and bold formatting, and empty-cell typing/undo. Conversion and formatting currently create separate undo transactions. Harness DOM assertions also cover renderer agreement and preservation. These checks do not replace full application export/reimport, public deployment checks or native PowerPoint raster evidence.\n\nTo browse the gallery with the linked package:\n\n```sh\ncd ../pptx-gallery\nOPF_LOCAL_WORKSPACE=1 pnpm dev\n```\n\nLayout detail pages have an interactive composition example. The flag expands Turbopack's local root to include the sibling package; production builds use the gallery root.\n\n`pnpm test:ecosystem` validates the dynamic composition fixture, edits and undoes a composition, renders SVG/PNG/PDF, exports editable PPTX, checks OOXML text-box coordinates against the shared geometry, and imports the result back into schema-valid OPF. Artifacts are written to a temporary directory and its location is printed.\n\nFor tests that should read current source without modifying installed packages, use Node's local loader after building OPF:\n\n```sh\nnode --import ./scripts/register-local-opf.mjs ../opf-render/test/smoke.mjs\n```\n\nThe loader redirects only `@openpresentation/opf` imports to this checkout. Ordinary dependencies still resolve from the consuming repository.\n\nFor full gallery render coverage, run `pnpm test:gallery -- --render` (or invoke the script with `--render`). The test validates all 854 generated documents and can render them with the local SVG engine.\n\nBuild OPF before starting a linked gallery. Stop and restart the gallery around clean OPF rebuilds; removing the linked `dist` directory during compilation can leave Turbopack with stale missing-module errors.\n\n`pnpm test:pagination` verifies long-text and table pagination through SVG and editable PPTX, including exact source reconstruction, table row counts, and absence of extra exporter-created pages. It writes review artifacts under `artifacts/pagination/`.\n\n`pnpm test:fonts` verifies actual-font measurement across editor, SVG, pagination, and PPTX. It also runs the offline font-switch matrix (`scripts/test-font-switch-ecosystem.mjs`, FF-09): a seeded pairwise covering array of 58 decks over the 14 gallery dimensions, plus fixed content-type, block-replacement, per-slide override, CJK-in-Latin, theme and language switches, each switched A to B and back to A. A value class is a group of catalog values that take the same path through the engines, derived from the catalogs in the script: font schemes by language family, then by licensing and preview policy (Office metric, Office visual-only, monospace, open Google); one language per script family in the array and every other catalog script in a language chain; every layout family; the eight content blocks; every distinct chart export path of the non-deprecated chart types; header/footer, background (theme, solid, gradient, pattern, image) and image treatments by kind; and the first and last record of the metadata dimensions. Dimensions that a deck can carry several times (font scheme states, layouts, blocks, charts, backgrounds, images) take several values per deck. Every state is exported and checked with the FF-08 typeface inventory, the catalog's literal theme fonts, a package structure check, a preview re-render and a re-import. It pins the office font pack with visual substitution and asserts every substitution; known engine limitations, including chart types the preview approximates or the exporter writes as bar charts, are named expected failures in the script that fail with a \"limitation resolved\" message when they go away. It runs no browser and no Office. Its report is written to `artifacts/font-switch-matrix/report.json`. The pairwise decks run on four worker threads with a fixed partition (deck i on worker i mod 4), so every host verifies each deck after the same earlier decks; `OPF_MATRIX_WORKERS=1` runs them in one thread, `OPF_MATRIX_WORKERS=n` on n workers. See [font fidelity](font-fidelity.md) for loading and embedding local fonts and for current native PowerPoint limits.\n\n## Coordinated CI: the ecosystem lock\n\n`ecosystem.lock.json` records the four OpenPresentation commits (`opf`, `opf-render`, `opf-pptx`, `opf-editor`) that passed the coordinated ecosystem checks together, and the golden baseline (`OPF_GOLDEN_BASELINE`) the locked renderer renders the core examples against. `scripts/ecosystem-lock.schema.json` is its schema and `node scripts/ecosystem-lock.mjs validate` checks it with the same rules.\n\n- **Who writes it.** The SHAs are written by the roller, never by hand. A pull request that moves goldens may change `golden` (a reviewed golden decision, like the `OPF_GOLDEN_BASELINE` edits it replaces).\n- **Guard.** `node scripts/ecosystem-lock.mjs guard` checks every locked SHA against its repository's `main` through the GitHub REST compare API (the equivalent of `git merge-base --is-ancestor <sha> main`, no clone), and flags a pull request that changes a locked SHA from a branch other than the roller's (`ecosystem-roll/*`). The `ecosystem-core` job runs it as a warning; the repository variable `ECOSYSTEM_LOCK_GUARD=blocking` (or `--blocking`) makes a finding fail the job.\n- **Reading it.** CI never pins a sibling by hand. Each job runs the composite action `.github/actions/ecosystem-refs` with its own repository as `consumer`; the action reads `ecosystem.lock.json` from its own commit and returns the commit to check out for each repository (`opf`, `opf_render`, `opf_pptx`, `opf_editor`) and the golden baseline relative to the workspace (`golden`, for example `opf/scripts/fixtures/opf-examples-png.audience-ids.sha256.json`). Core's workflows use `./opf/.github/actions/ecosystem-refs` (the lock of the commit under test); the sibling repositories use `OpenPresentation/opf/.github/actions/ecosystem-refs@main` (the lock on core `main`). `export-golden: 'true'` exports `OPF_GOLDEN_BASELINE`; `golden-override` replaces the lock's golden with a workspace-relative baseline (a renderer pull request that moves pixels). Locally: `node scripts/ecosystem-lock.mjs resolve --consumer opf`.\n- **Depends-On.** For a change that needs an unmerged pull request of another ecosystem repository, add a line to the pull request body, for example `Depends-On: OpenPresentation/opf#264` (several pull requests may be listed, comma-separated or on several lines; the `https://github.com/OpenPresentation/<repository>/pull/<number>` form works too). On `pull_request` and `merge_group` events the action then checks that repository out at the named pull request instead of the lock: its test merge commit (`refs/pull/<n>/merge`) while it is open and mergeable, its head while it has conflicts, and its merge commit once it is merged (so a dependent pull request needs no edit after its dependency merges; re-run its checks). A closed, unmerged dependency fails the step; another organization's repository, a repository outside the four and a dependency on the pull request's own repository are reported and ignored; trailers inside fenced code blocks do not count. The body is read through the REST API, so after editing it re-run the checks. In the merge queue the pull request is the head pull request of the queue branch (`gh-readonly-queue/main/pr-<n>-<sha>`), so a breaking coordinated change is checked against its open sibling pull requests there too. In a batch of more than one pull request only that head pull request's `Depends-On` applies, so queue a coordinated change alone. `push` and scheduled runs always use the lock, so main is red for the sibling suites until the dependencies merge and the roll moves the lock. This replaces throwaway pin branches and repin pull requests. Check a body locally with `node scripts/ecosystem-lock.mjs depends-on --body-file body.md`. A renderer dependency that moves pixels brings its own golden: when opf-render comes from a `Depends-On` pull request and the step has no `golden-override` of its own, the action adopts the `golden-override` of that pull request's `.github/workflows/ci.yml` (the roller's rule: an `opf-render/test/golden/<name>` baseline or an `opf/scripts/fixtures/<name>.sha256.json` fixture, which must exist at the commit that is checked out) and logs `golden ... adopted from Depends-On OpenPresentation/opf-render#<n>`. `merge_group` runs keep the lock's golden, so the lock must still describe the locked renderer (FA-19).\n- **Roller.** `scripts/ecosystem-roll.mjs` (workflow \"Ecosystem lock roller\", `.github/workflows/ecosystem-roll.yml`) tries the four `main` branches together: it builds the candidate lock (the four `main` SHAs; the lock's golden, or opf-render main's `golden-override` when it sets one), force-moves `ecosystem-roll/main` to core `main`, commits the candidate there, and proposes it as a pull request only when the coordinated checks pass. It never writes `main`. `node scripts/ecosystem-roll.mjs plan --lock ecosystem.lock.json` shows the candidate without writing.\n - **Without the GitHub App (today).** The roller runs with `GITHUB_TOKEN`, whose pushes start no workflow and whose pull requests start no checks. So it dispatches `Coordinated public packages` and `OPF CI` on its branch (a `workflow_dispatch` made with `GITHUB_TOKEN` does start a run), waits for them, and proposes only a green candidate; those runs report the required checks on the branch head, which is the pull request head. The repository does not let GitHub Actions open pull requests (\"Allow GitHub Actions to create and approve pull requests\" is off), so a green roll ends with a compare link in the job summary and a maintainer opens the pull request. The workflow is dispatch-only: the hourly schedule stays commented out.\n - **With the App ([opf#298](https://github.com/OpenPresentation/opf/issues/298)).** Set the repository variable `ECOSYSTEM_APP_ID` and the secret `ECOSYSTEM_APP_PRIVATE_KEY`: the workflow's token step then runs and the roller uses the App token with no code change, opening the pull request itself (its normal checks decide). Then uncomment the schedule, and let each sibling send a `repository_dispatch` of type `ecosystem-main-updated` after a merge to `main`.\n- **No hand pins.** `scripts/ecosystem-lock.test.mjs` fails when a core workflow checks out an OpenPresentation repository at a hand-written SHA or selects a hand-written `OPF_GOLDEN_BASELINE`. The lock was generated from the last hand pins of `.github/workflows/ecosystem-ci.yml`; see [the CI study](programs/release-readiness/ci-cd.md), section 3.\n\n## Regenerating core's golden fixture\n\nA pull request that changes `examples/`, or the core code that composes them, moves the corpus digest and slide hashes in the fixture `ecosystem.lock.json` `golden` selects (`scripts/fixtures/opf-examples-png.*.sha256.json`; `scripts/fixtures/README.md` keeps the review record). opf-render's `regenerate-goldens` reads only the lock, so it cannot see an unmerged core pull request. The `regenerate-core-golden` workflow (`.github/workflows/regenerate-core-golden.yml`, helpers in `scripts/core-golden.mjs`, tests in `pnpm check:core-golden`) does it for a branch of this repository, with the method of RR-20 (opf#367): the locked renderer's `test/golden.mjs`, core linked, scale 0.25, `systemFonts: false`, on Linux in the Playwright image `ecosystem-ci.yml` pins, Node 24.\n\n```sh\ngh workflow run regenerate-core-golden.yml -f branch=<branch> # render, push one fixture commit, start CI\ngh workflow run regenerate-core-golden.yml -f branch=<branch> -f dry-run=true # render and report only\n```\n\n- **Inputs.** `branch` (required): a branch of this repository, never the default branch; a fork (`owner:branch`), a ref, a SHA or any other name is refused, and the branch is checked out from this repository only. `dry-run` (default false) renders and writes the report without pushing. `dispatch-ci` (default true) starts `OPF CI` and `Coordinated public packages` on the branch after the push (a push with `GITHUB_TOKEN` starts no workflow). Dispatch it on `main`: a `workflow_dispatch` sees only workflows on the default branch, and the run refuses any other ref.\n- **Step one, the reproducibility guard.** It renders main's examples with main's core and the locked renderer (the renderer commit in `ecosystem.lock.json`) and requires the generated fixture to be byte-identical to main's committed one. If it is not, the run fails and pushes nothing: the environment cannot be trusted to write a fixture (or main's fixture is stale), and the run log lists the slides that differ.\n- **Step two.** It renders the branch head the same way. The fixture is written to the path the branch's `ecosystem.lock.json` selects; the commit may touch only that one `scripts/fixtures/<name>.sha256.json` (any other change fails the run), its message lists every changed slide (deck, index, old and new hash) compared with main's fixture, and it is pushed to the branch without force by `github-actions[bot]`. The push is a second job that holds the write token and runs only scripts from `main`; the branch's code (install, build, render) runs in a job with read access only. A branch that moved while rendering, a branch whose lock locks another renderer than main (rebase), and a branch that lacks a change on `main` to `examples/`, `packages/`, `spec/`, `scripts/fixtures/`, the lock or the lockfile (rebase) fail without pushing. A fixture that already equals the render, or `dry-run`, pushes nothing.\n- **A renderer that is not merged yet (`renderer-ref`).** For one coordinated change whose renderer is still a branch (OPF 0.15 with FA-23), pass `-f renderer-ref=<opf-render branch or SHA> -f fixture-name=<name>`, and dispatch on the branch itself (`--ref <branch>`) until `main` carries this workflow version. The guard is unchanged (main with the locked renderer). The branch is rendered with the `renderer-ref` renderer into a new fixture, `scripts/fixtures/opf-examples-png.<name>.sha256.json` (never main's, which main's lock still selects); the push also points the branch's `ecosystem.lock.json` `golden` at it and appends the review record, with the renderer SHA, to `scripts/fixtures/README.md`, and the commit message names the renderer SHA. The \"branch lock renderer must equal main's\" check is relaxed for such a run, and the log says why.\n- **Job summary and artifact.** The summary lists the changed slides per deck, ready to paste into the pull request body (add the reason for each change), and every old and new hash; the `core-golden-report` artifact holds the same (`changes.json`, `summary.md`, `commit-message.txt`, the fixture) and the review sheets and PNGs of the slides that differ from main's fixture. Look at them before approving: the commit is evidence, not an approval.\n- **Merge rhythm.** Merges are sequential and each examples-touching merge changes main's fixture. After every such merge, the next examples-touching pull request rebases onto main and dispatches this workflow, then merges once CI on the new commit is green. Edit the pull request body with the list before merging.\n- **CI after the push.** A dispatched run reads `ecosystem.lock.json` only, so for a pull request whose body has `Depends-On:` lines close and reopen it (or push) to run its own `pull_request` checks.\n- **Limits.** The renderer is the locked one; a pull request that also needs an unmerged opf-render change cannot be regenerated here. A fixture in another repository (a lock `golden` that names opf-render) is refused.\n\n## Coordinated CI: the contract tier\n\n`Coordinated public packages` (`.github/workflows/ecosystem-ci.yml`) runs the three siblings' suites in two depths ([the CI study](programs/release-readiness/ci-cd.md), section 3, consumer-driven contracts). `scripts/ecosystem-scope.mjs` (`tierForEvent`) decides the depth and every sibling shard passes it as `--tier` to `scripts/test-package-ecosystem.mjs` (`scripts/package-ecosystem-plan.mjs` lists the commands).\n\n| Event | Depth | What a sibling runs |\n|---|---|---|\n| `pull_request` | `contract` | `npm run test:contract` (instead of `npm run test`) |\n| `merge_group`, the nightly `schedule` (01:37 UTC), `workflow_dispatch` | `full` | `npm run test` |\n| `pull_request` that changes `ecosystem.lock.json`, the workflow, the tiering scripts or the `ecosystem-refs` action, comes from an `ecosystem-roll/*` branch or has the label `ecosystem-full` or `full-ci` | `full` | `npm run test` |\n\n- **The contract suite.** Each sibling defines it in `test/suites.json`: `contractExclude` names the tests of `npm test` (`scripts/run-tests.mjs`) that the contract leaves out, each with a reason (tests of the package's own machinery that take no input from core). Every other test is in the contract, so a new test gates core pull requests until someone excludes it on purpose. `node scripts/run-tests.mjs --suite contract --list` prints it; the sibling's `check:runners` fails on an exclusion that names a missing file or has no reason. `test:contract` also keeps the cheap build steps that bundle core (the renderer's font licence check and browser bundle, the converter's browser bundle). Nothing else in a shard changes: typecheck, validate, `test:code`, `test:font-preparation`, `test:font-variants`, the browser steps and the golden gate (805 raster hashes, exact) run at both depths, and no tolerance moved.\n- **Nothing is skipped by the tier.** Only a pull request can run `contract`; a docs-only or contract-only change never lowers a `merge_group`, `main` or nightly run (`scripts/ecosystem-scope.test.mjs`). A sibling whose commit has no `test:contract` script (a lock that predates RR-53) runs its full `npm run test` with a warning.\n- **The merge queue and the nightly run (RR-57).** The queue's `merge_group` run is the full gate before main, and main is fast-forwarded to the exact commit it tested, so there is no push-to-main run (it repeated the same SHA; `scripts/release-train.mjs` reads the queue run's check runs on that commit). The nightly run is the safety net for anything that reaches main without the queue. A red nightly run fails the workflow run and the job `main-status` keeps an issue labelled `main-red` open for the red period (a comment for each further red run, closed by the next green one); poll it with `gh api 'repos/OpenPresentation/opf/issues?labels=main-red&state=open'`. Fix forward or revert. Add the label `ecosystem-full` (or `full-ci`) to a pull request that you know reaches past the contract (the font catalogs, the script packs, the PDF path) to run the full suites on it; the label takes effect on the next push or a re-run.\n- **Merge order.** A sibling pull request that adds or changes its contract merges first; the roller then moves the lock. Core's pull request tier reads the contract at the lock's commit, or at the pull request named by a `Depends-On:` line.\n"
86
86
  },
87
87
  {
88
88
  "slug": "evidence-2026-09-08-windows",
@@ -94,19 +94,19 @@ var docsData = Object.freeze([
94
94
  "slug": "examples",
95
95
  "file": "docs/examples.md",
96
96
  "title": "OPF Examples Guide",
97
- "markdown": "# OPF Examples Guide\n\nThe `examples/` directory has two shipped layers, plus a docs fixture kept outside the catalog:\n\n- `examples/technical/` contains compact fixtures that isolate one or two schema behaviors.\n- `examples/gallery/` contains scenario-oriented decks that show OPF working across industries, functions, education, government, international, presentation-type, and design/media use cases.\n- The representative deck for [the published-package quickstart](quickstart.md) lives at [`docs/quickstart/developer-quickstart.opf.json`](quickstart/developer-quickstart.opf.json), outside the catalog, so `@openpresentation/opf/examples` stays at the published example count (currently 127 decks); the renderer golden corpus tracks that catalog on its own release cadence.\n- `examples/markdown/` holds decks written in the [Markdown dialect](markdown.md) (`.md`, not `.opf.json`, so they are outside the catalog count): `quarterly-review.md` uses every block kind and is canonical, `outline.md` is a plain outline read with `opf from-md --split headings`. The tests convert, validate, compose and round trip them.\n- The examples root is kept as an organizing directory rather than a home for standalone OPF files.\n\n## Technical Fixtures\n\nUse `examples/technical/` when you want a small file that exercises a specific schema surface:\n\n- content payloads, rich text, blocks, charts, tables, media, metrics, quotes, and timelines\n- promoted region keys and span combinations\n- asset string/object forms and asset-backed chart data\n- design backgrounds, logo sets, headers, footers, watermarks, and slide-level overrides\n- metadata array forms, language metadata, custom narrative records, and catalog overrides\n\n## Gallery Folders\n\n| Folder | What It Demonstrates |\n| --- | --- |\n| `industries/` | Vertical market decks with operating plans, investment briefs, readiness reviews, and launch coordination. |\n| `business-functions/` | Department-specific decks for sales, marketing, product, engineering, finance, HR, legal, security, support, procurement, and strategy. |\n| `education/` | K-12, higher education, research, advising, workforce, advancement, and student services scenarios. |\n| `government/` | Public health, transit, emergency management, utilities, regulators, courts, parks, workforce, tax, and civic engagement decks. |\n| `presentation-types/` | Reusable deck archetypes such as pitches, board updates, QBRs, conference talks, workshops, postmortems, launches, policy briefings, training, and research reports. |\n| `international/` | Region- or language-specific decks, including examples of language object metadata and right-to-left direction. |\n| `design-and-media/` | Decks that emphasize design controls, image/video assets, data storytelling, and self-running orientation patterns. |\n\n## Patterns To Look For\n\n- Technical fixtures that isolate validator and renderer behavior.\n- Sparse gallery documents that use shorthand catalog references and a small slide list.\n- Medium documents with schema ids, metadata, organization and speaker records, design overrides, assets, and richer slide payloads.\n- Dense documents with inline `catalogs` sources and records, promoted region keys, `blocks`, media assets, code payloads, header/footer configuration, logo sets, watermarks, and extensions.\n- Mixed content payloads across text, bullets, lists, image, video, chart, table, code, metric, quote, and timeline slides.\n- Catalog references across narratives, layouts, chart types, themes, color schemes, font schemes, languages, audiences, purposes, tones, and social platforms.\n\n## Validation\n\nRun the example validator after changing any `*.opf.json` file:\n\n```sh\nnode scripts/validate-examples.mjs\n```\n\nThe script walks every OPF document under `examples/` and reports schema or semantic validation issues with file paths.\n"
97
+ "markdown": "# OPF Examples Guide\n\nThe `examples/` directory has two shipped layers, plus a docs fixture kept outside the catalog:\n\n- `examples/technical/` contains compact fixtures that isolate one or two schema behaviors.\n- `examples/gallery/` contains scenario-oriented decks that show OPF working across industries, functions, education, government, international, presentation-type, and design/media use cases.\n- The representative deck for [the published-package quickstart](quickstart.md) lives at [`docs/quickstart/developer-quickstart.opf.json`](quickstart/developer-quickstart.opf.json), outside the catalog, so `@openpresentation/opf/examples` stays at the published example count (currently 127 decks); the renderer golden corpus tracks that catalog on its own release cadence.\n- `examples/markdown/` holds decks written in the [Markdown dialect](markdown.md) (`.md`, not `.opf.json`, so they are outside the catalog count): `quarterly-review.md` uses every block kind and is canonical, `outline.md` is a plain outline read with `opf from-md --split headings`. The tests convert, validate, compose and round trip them.\n- The examples root is kept as an organizing directory rather than a home for standalone OPF files.\n\n## Technical Fixtures\n\nUse `examples/technical/` when you want a small file that exercises a specific schema surface:\n\n- content payloads, rich text, blocks, charts, tables, media, metrics, quotes, and timelines\n- promoted region keys and span combinations\n- asset string/object forms and asset-backed chart data\n- design backgrounds, logo sets, headers, footers, watermarks, and slide-level overrides\n- metadata array forms, language metadata, custom narrative records, and catalog overrides\n\n## Gallery Folders\n\n| Folder | What It Demonstrates |\n| --- | --- |\n| `industries/` | Vertical market decks with operating plans, investment briefs, readiness reviews, and launch coordination. |\n| `business-functions/` | Department-specific decks for sales, marketing, product, engineering, finance, HR, legal, security, support, procurement, and strategy. |\n| `education/` | K-12, higher education, research, advising, workforce, advancement, and student services scenarios. |\n| `government/` | Public health, transit, emergency management, utilities, regulators, courts, parks, workforce, tax, and civic engagement decks. |\n| `presentation-types/` | Reusable deck archetypes such as pitches, board updates, QBRs, conference talks, workshops, postmortems, launches, policy briefings, training, and research reports. |\n| `international/` | Region- or language-specific decks, including examples of language object metadata and right-to-left direction. |\n| `design-and-media/` | Decks that emphasize design controls, image/video assets, data storytelling, and self-running orientation patterns. |\n\n## Patterns To Look For\n\n- Technical fixtures that isolate validator and renderer behavior.\n- Sparse gallery documents that use shorthand catalog references and a small slide list.\n- Medium documents with schema ids, metadata, organization and speaker records, design overrides, assets, and richer slide payloads.\n- Dense documents with their own records in `catalogs.custom`, promoted region keys, `blocks`, media assets, code payloads, header/footer configuration, logo sets, watermarks, and extensions.\n- Mixed content payloads across text, bullets, lists, image, video, chart, table, code, metric, quote, and timeline slides.\n- Catalog references across narratives, layouts, themes, color schemes, font schemes, audiences, purposes and tones, every record embedded under `catalogs.default` or `catalogs.custom`, so each deck validates and renders with no catalog registered; chart types, languages and social platforms as engine vocabularies.\n\n## Validation\n\nRun the example validator after changing any `*.opf.json` file:\n\n```sh\nnode scripts/validate-examples.mjs\n```\n\nThe script walks every OPF document under `examples/` and reports schema or semantic validation issues with file paths.\n"
98
98
  },
99
99
  {
100
100
  "slug": "finding-schema-reference",
101
101
  "file": "docs/finding-schema-reference.md",
102
102
  "title": "OPF Finding Schema Reference",
103
- "markdown": "# OPF Finding Schema Reference\n\nA finding is one thing a checker found at one place in a presentation, and a report lists them. `validate` in `@openpresentation/opf`, the Markdown and YAML converters, the editor's Review panel and hosted reviewers such as pptx.dev's AI review all produce this shape, so a host shows and applies findings from any of them the same way. This page summarizes `spec/schemas/finding.schema.json`; the schema remains the source of truth. How core produces findings is in [validate](validate.md).\n\n## Report Contract\n\n- Schema id: `https://openpresentation.org/schema/opf-finding/v1`\n- Required top-level fields: `valid`, `findings`, `counts`\n- Additional top-level fields: allowed (`validate` adds `schemaValid`, `checks`, `template` and `unfilledVariables`)\n\n## Top-Level Fields\n\n| Field | Required | Type | Notes |\n| --- | --- | --- | --- |\n| `valid` | yes | `boolean` | True when no finding has severity `error`. |\n| `findings` | yes | `array<ref:Finding>` | Every finding, in report order. |\n| `counts` | yes | `object` | How many findings each severity has. |\n\n## Object And Type Reference\n\n### FindingSeverity\n\n- Type: `enum:error | warning | info`\n- Required fields: none\n- Purpose: `error` makes a report invalid. `warning` is a problem the author should fix. `info` is advisory.\n\n_No named properties._\n\n\n### FindingCategory\n\n- Type: `anyOf:enum:format | references | policy | accessibility | layout | content / string`\n- Required fields: none\n- Purpose: What kind of question the finding answers. Core uses `format` (is it well-formed OPF), `references` (does everything it points at resolve), `policy` (host house rules), `accessibility`, `layout` (will it present as authored) and `content` (is anything left unfinished). Other producers may use their own category names, such as `narrative` or `audience-fit`.\n\n_No named properties._\n\n\n### FindingLocation\n\n- Type: `object`\n- Required fields: `offset`, `length`, `line`, `column`\n- Purpose: A range in the checked text: UTF-16 offset and length from the start, and the one-based line and column of the start. Present when the finding was produced from text (JSON, Markdown or YAML).\n\n| Field | Required | Type | Notes |\n| --- | --- | --- | --- |\n| `offset` | yes | `integer` | |\n| `length` | yes | `integer` | |\n| `line` | yes | `integer` | |\n| `column` | yes | `integer` | |\n\n\n### JsonPatchOperation\n\n- Type: `oneOf:object / object / object`\n- Required fields: none\n- Purpose: One RFC 6902 JSON Patch operation. Paths are JSON Pointers into the checked presentation.\n\n_No named properties._\n\n\n### FindingFocus\n\n- Type: `object`\n- Required fields: `path`, `field`\n- Purpose: The field an author has to fill in, for a fix that cannot be written as a patch.\n\n| Field | Required | Type | Notes |\n| --- | --- | --- | --- |\n| `path` | yes | `string` | JSON Pointer of the field to edit. |\n| `field` | yes | `string` | What the field is, such as `alt`, `title`, `text`, `link`, `language` or `fontSize`. |\n| `value` | no | `string \\| number \\| boolean \\| null \\| array \\| object` | The field's current value, when it has one. |\n\n\n### FindingFix\n\n- Type: `object`\n- Required fields: `title`\n- Purpose: A suggested repair. Core never applies it: the host shows it and applies it through its own undoable edit path. A `patch` fix carries JSON Patch operations; a `focus` fix only names the field the author has to fill in.\n\n| Field | Required | Type | Notes |\n| --- | --- | --- | --- |\n| `title` | yes | `string` | What applying the fix does, in a few words. |\n| `id` | no | `string` | Stable id within one finding, such as `focus-alt` or `use-readable-color`. |\n| `kind` | no | `enum:patch \\| focus` | `patch` fixes carry `patch`; `focus` fixes carry `focus`. |\n| `safe` | no | `boolean` | True when applying the fix cannot change what the content says or how it looks beyond the repair, such as switching a failing colour to the one the theme chose. False fixes need a deliberate choice. |\n| `patch` | no | `array<ref:JsonPatchOperation>` | RFC 6902 JSON Patch operations that apply the fix to the checked presentation. |\n| `focus` | no | `ref:FindingFocus` | |\n\n\n### FindingSuggestion\n\n- Type: `object`\n- Required fields: `value`, `label`, `origin`\n- Purpose: A value the author could use instead, such as a catalog record that exists. Suggestions name records actually present in the checked context and never replace authored values silently.\n\n| Field | Required | Type | Notes |\n| --- | --- | --- | --- |\n| `value` | yes | `string \\| number \\| boolean \\| null` | |\n| `label` | yes | `string` | |\n| `origin` | yes | `string` | Where the suggestion comes from: `schema`, `built-in`, `loaded`, `document` or `contract`. |\n| `definition` | no | `string` | Where the suggested record or constraint is defined. |\n\n\n### ValidationIssue\n\n- Type: `object`\n- Required fields: `path`, `message`, `keyword`, `schemaPath`, `params`\n- Purpose: The raw schema or semantic issue behind a finding, kept with every union alternative so a host can inspect the exact constraint.\n\n| Field | Required | Type | Notes |\n| --- | --- | --- | --- |\n| `path` | yes | `string` | |\n| `message` | yes | `string` | |\n| `keyword` | yes | `string` | The JSON Schema keyword that failed, or `opf` for a rule the schema cannot express. |\n| `schemaPath` | yes | `string` | |\n| `params` | yes | `object` | |\n\n\n### Finding\n\n- Type: `object`\n- Required fields: `ruleId`, `severity`, `category`, `path`, `message`\n- Purpose: One thing a checker found at one place in a presentation.\n\n| Field | Required | Type | Notes |\n| --- | --- | --- | --- |\n| `ruleId` | yes | `string` | Stable `<producer>/<rule>` id, such as `opf/text-contrast` for core or `pptx.dev/narrative-gap` for a hosted reviewer. Ids do not change between releases. |\n| `source` | no | `string` | The producer: `opf` (the default) for core, or the name of another tool such as `pptx.dev/review`. |\n| `severity` | yes | `ref:FindingSeverity` | |\n| `category` | yes | `ref:FindingCategory` | |\n| `path` | yes | `string` | JSON Pointer to the value the finding is about, such as `/slides/4/title`. Empty for the whole document, or for a value in a supplied context such as a loaded catalog. |\n| `scope` | no | `enum:document \\| context` | `document` (the default) when `path` points into the checked presentation, `context` when it points into host-supplied context such as loaded catalog records. |\n| `message` | yes | `string` | What is wrong, in a sentence. |\n| `help` | no | `string` | What to do about it. |\n| `slide` | no | `integer` | Zero-based index of the slide the finding belongs to. |\n| `slideId` | no | `string` | The id of that slide, when it has one. |\n| `location` | no | `ref:FindingLocation` | |\n| `definition` | no | `string` | A link to the rule's documentation or to the schema constraint or catalog record the finding is about. |\n| `lookup` | no | `array<string>` | Arguments for the `opf schema` or `opf catalog` command that shows the definition, as an argument list rather than a shell string. |\n| `suggestions` | no | `array<ref:FindingSuggestion>` | |\n| `measured` | no | `object` | The numbers behind the finding, such as `{ \"ratio\": 2.1, \"needed\": 4.5 }`. |\n| `fixes` | no | `array<ref:FindingFix>` | |\n| `validation` | no | `ref:ValidationIssue` | |\n"
103
+ "markdown": "# OPF Finding Schema Reference\n\nA finding is one thing a checker found at one place in a presentation, and a report lists them. `validate` in `@openpresentation/opf`, the Markdown and YAML converters, the editor's Review panel and hosted reviewers such as pptx.dev's AI review all produce this shape, so a host shows and applies findings from any of them the same way. This page summarizes `spec/schemas/finding.schema.json`; the schema remains the source of truth. How core produces findings is in [validate](validate.md).\n\n## Report Contract\n\n- Schema id: `https://openpresentation.org/schema/opf-finding/v1`\n- Required top-level fields: `valid`, `findings`, `counts`\n- Additional top-level fields: allowed (`validate` adds `schemaValid`, `checks`, `template` and `unfilledVariables`)\n\n## Top-Level Fields\n\n| Field | Required | Type | Notes |\n| --- | --- | --- | --- |\n| `valid` | yes | `boolean` | True when no finding has severity `error`. |\n| `findings` | yes | `array<ref:Finding>` | Every finding, in report order. |\n| `counts` | yes | `object` | How many findings each severity has. |\n\n## Object And Type Reference\n\n### FindingSeverity\n\n- Type: `enum:error | warning | info`\n- Required fields: none\n- Purpose: `error` makes a report invalid. `warning` is a problem the author should fix. `info` is advisory.\n\n_No named properties._\n\n\n### FindingCategory\n\n- Type: `anyOf:enum:format | references | policy | accessibility | layout | content / string`\n- Required fields: none\n- Purpose: What kind of question the finding answers. Core uses `format` (is it well-formed OPF), `references` (does everything it points at resolve), `policy` (host house rules), `accessibility`, `layout` (will it present as authored) and `content` (is anything left unfinished). Other producers may use their own category names, such as `narrative` or `audience-fit`.\n\n_No named properties._\n\n\n### FindingLocation\n\n- Type: `object`\n- Required fields: `offset`, `length`, `line`, `column`\n- Purpose: A range in the checked text: UTF-16 offset and length from the start, and the one-based line and column of the start. Present when the finding was produced from text (JSON, Markdown or YAML).\n\n| Field | Required | Type | Notes |\n| --- | --- | --- | --- |\n| `offset` | yes | `integer` | |\n| `length` | yes | `integer` | |\n| `line` | yes | `integer` | |\n| `column` | yes | `integer` | |\n\n\n### JsonPatchOperation\n\n- Type: `oneOf:object / object / object`\n- Required fields: none\n- Purpose: One RFC 6902 JSON Patch operation. Paths are JSON Pointers into the checked presentation.\n\n_No named properties._\n\n\n### FindingFocus\n\n- Type: `object`\n- Required fields: `path`, `field`\n- Purpose: The field an author has to fill in, for a fix that cannot be written as a patch.\n\n| Field | Required | Type | Notes |\n| --- | --- | --- | --- |\n| `path` | yes | `string` | JSON Pointer of the field to edit. |\n| `field` | yes | `string` | What the field is, such as `alt`, `title`, `text`, `link`, `language` or `fontSize`. |\n| `value` | no | `string \\| number \\| boolean \\| null \\| array \\| object` | The field's current value, when it has one. |\n\n\n### FindingFix\n\n- Type: `object`\n- Required fields: `title`\n- Purpose: A suggested repair. Core never applies it: the host shows it and applies it through its own undoable edit path. A `patch` fix carries JSON Patch operations; a `focus` fix only names the field the author has to fill in.\n\n| Field | Required | Type | Notes |\n| --- | --- | --- | --- |\n| `title` | yes | `string` | What applying the fix does, in a few words. |\n| `id` | no | `string` | Stable id within one finding, such as `focus-alt` or `use-readable-color`. |\n| `kind` | no | `enum:patch \\| focus` | `patch` fixes carry `patch`; `focus` fixes carry `focus`. |\n| `safe` | no | `boolean` | True when applying the fix cannot change what the content says or how it looks beyond the repair, such as switching a failing colour to the one the theme chose. False fixes need a deliberate choice. |\n| `patch` | no | `array<ref:JsonPatchOperation>` | RFC 6902 JSON Patch operations that apply the fix to the checked presentation. |\n| `focus` | no | `ref:FindingFocus` | |\n\n\n### FindingSuggestion\n\n- Type: `object`\n- Required fields: `value`, `label`, `origin`\n- Purpose: A value the author could use instead, such as a catalog record that exists. Suggestions name records actually present in the checked context and never replace authored values silently.\n\n| Field | Required | Type | Notes |\n| --- | --- | --- | --- |\n| `value` | yes | `string \\| number \\| boolean \\| null` | |\n| `label` | yes | `string` | |\n| `origin` | yes | `string` | Where the suggestion comes from: `schema`, `document` (a record the document embeds), `registered` (a catalog the host registered) or `contract`. |\n| `definition` | no | `string` | Where the suggested record or constraint is defined. |\n\n\n### ValidationIssue\n\n- Type: `object`\n- Required fields: `path`, `message`, `keyword`, `schemaPath`, `params`\n- Purpose: The raw schema or semantic issue behind a finding, kept with every union alternative so a host can inspect the exact constraint.\n\n| Field | Required | Type | Notes |\n| --- | --- | --- | --- |\n| `path` | yes | `string` | |\n| `message` | yes | `string` | |\n| `keyword` | yes | `string` | The JSON Schema keyword that failed, or `opf` for a rule the schema cannot express. |\n| `schemaPath` | yes | `string` | |\n| `params` | yes | `object` | |\n\n\n### Finding\n\n- Type: `object`\n- Required fields: `ruleId`, `severity`, `category`, `path`, `message`\n- Purpose: One thing a checker found at one place in a presentation.\n\n| Field | Required | Type | Notes |\n| --- | --- | --- | --- |\n| `ruleId` | yes | `string` | Stable `<producer>/<rule>` id, such as `opf/text-contrast` for core or `pptx.dev/narrative-gap` for a hosted reviewer. Ids do not change between releases. |\n| `source` | no | `string` | The producer: `opf` (the default) for core, or the name of another tool such as `pptx.dev/review`. |\n| `severity` | yes | `ref:FindingSeverity` | |\n| `category` | yes | `ref:FindingCategory` | |\n| `path` | yes | `string` | JSON Pointer to the value the finding is about, such as `/slides/4/title`. Empty for the whole document, or for a value in a supplied context such as a loaded catalog. |\n| `scope` | no | `enum:document \\| context` | `document` (the default) when `path` points into the checked presentation, `context` when it points into host-supplied context such as loaded catalog records. |\n| `message` | yes | `string` | What is wrong, in a sentence. |\n| `help` | no | `string` | What to do about it. |\n| `slide` | no | `integer` | Zero-based index of the slide the finding belongs to. |\n| `slideId` | no | `string` | The id of that slide, when it has one. |\n| `location` | no | `ref:FindingLocation` | |\n| `definition` | no | `string` | A link to the rule's documentation or to the schema constraint or catalog record the finding is about. |\n| `lookup` | no | `array<string>` | Arguments for the `opf schema` or `opf catalog` command that shows the definition, as an argument list rather than a shell string. |\n| `suggestions` | no | `array<ref:FindingSuggestion>` | |\n| `measured` | no | `object` | The numbers behind the finding, such as `{ \"ratio\": 2.1, \"needed\": 4.5 }`. |\n| `fixes` | no | `array<ref:FindingFix>` | |\n| `validation` | no | `ref:ValidationIssue` | |\n"
104
104
  },
105
105
  {
106
106
  "slug": "font-fidelity",
107
107
  "file": "docs/font-fidelity.md",
108
108
  "title": "Measured fonts and reproducible previews",
109
- "markdown": "# Measured fonts and reproducible previews\n\nFor the starter set and delivery priorities, see the [font roadmap](plans/font-roadmap.md).\n\n## Font policy (FF-31)\n\nOPF keeps one machine-readable font policy table, [`spec/reference/font-policy.json`](../spec/reference/font-policy.json). Core exports it as `FONT_POLICY`, `fontPolicyFor()` and `applyFontPolicyDecisions()` from `@openpresentation/opf` or `@openpresentation/opf/font-policy`. Each row gives a family's license class, where viewers get it, whether OPF may ever embed it, and its preview replacement with a measured width difference. It also lists alternates, ending where possible with a face that already ships with opf-render. The [licensing table](programs/font-fidelity-everywhere/font-licensing.md) lists all 153 rows. [`font-policy.schema.json`](../spec/reference/font-policy.schema.json) is its JSON Schema. The [measurement evidence](evidence/font-replacements-20260923/README.md) explains how each replacement was chosen.\n\n**The policy in brief (owner decisions, 2026-09-29).** This is the canonical statement; the sibling repositories link here.\n\n1. **The user's font selection is the source of truth.** The user picks a font, for example Aptos or Calibri through a font scheme. That name is what the document, the theme and the PPTX carry.\n2. **License-restricted (proprietary) fonts are never bundled or embedded,** so previews cannot draw them. Openly licensed fonts such as Carlito and Roboto are bundled, and \"licensed\" below always means license-restricted. Live previews, SVG, the editor and gallery thumbnails use an open look-alike instead. The goal is a replacement that looks similar and is metric-compatible (same advance widths and line metrics), so text occupies the same size on screen and wraps as it does in PowerPoint. Calibri\u2192Carlito is the model.\n3. **Where no metric-compatible open replacement exists yet,** a visual-only look-alike is a documented fallback. It is reported as visual and is a known layout-fidelity gap to close, not the intended end state. The parity scoreboard counts it as near, not perfect. The Aptos family is no longer such a case: Intos previews it as metric.\n4. **PPTX export always writes the selected font name,** for example `typeface=\"Aptos\"` in the theme and in runs, never the replacement. PowerPoint then opens the file and shows the actual font, installed or as a Microsoft 365 cloud font.\n5. **Only open fonts may be embedded,** through the explicit embed path (FF-13).\n6. **Font files are bundled, never hotlinked.** Fonts, Google Fonts included, ship as pinned files (an exact npm version, or a vendored file with a recorded sha256) and are never loaded at runtime from a font CDN. That protects visitor privacy (a German court held the Google Fonts CDN a GDPR violation in 2022) and keeps previews offline-capable and audits reproducible. Build-time self-hosting such as `next/font/google` is not a hotlink but is not pinned, so use `next/font/local` with pinned files. Guards fail on CDN references in every repository.\n7. **Every bundled face records a verified permissive license.** Allowed: exactly OFL-1.1, Apache-2.0, MIT and UFL-1.0; not GPL, LGPL, AGPL, proprietary or unclear public-domain fonts. The record has the SPDX id, any Reserved Font Name the copyright block declares, the source URL, package@version and sha256, all checked against the LICENSE file the font ships with. A modified version (subset, instance, conversion) may not use a Reserved Font Name in its name: a family whose served name contains its reserved name (Carlito, Raleway) is bundled only as the unmodified upstream file, while one that reserves another name (Noto Sans JP reserves \"Source\") may be modified. Details and enforcement: [Font files: bundling and licenses](programs/font-fidelity-everywhere/font-licensing.md#font-files-bundling-and-licenses).\n\n**Release status.** Point 4 is published: opf-pptx 0.10.0 and later (current 0.13.2) write the selected name into the PPTX, and the editor and pptx.gallery builds that depend on the release-plan set export it. opf-pptx 0.9.1 and earlier wrote the substitute.\n\n**Provisional owner decisions (provisional, owner may revise).** Three choices about which open face stands in for a family are pending with the owner. The policy above is settled; only these replacement picks are provisional. Root resolved them provisionally with the recommended defaults:\n\n| Decision | Families | Replacement |\n| --- | --- | --- |\n| `aptos-preview` | Aptos (the default `aptos` scheme) | Intos (metric, owner policy 2026-09-29; Roboto and Carlito are alternates) |\n| `segoe-ui-preview` | Segoe UI, Semibold, Light and Semilight | Red Hat Display (visual) |\n| `cambria-tier` | Cambria | Caladea, reclassified from metric to visual. Its `metricModeFallback` keeps metric-mode registries previewing Cambria with Caladea, reported as visual, as they did before FF-31. |\n\nAll three live in one block, `provisionalDecisions`, at the top of the JSON. The rows that follow a decision carry no replacement family of their own. A change of decision is therefore a one-line edit. When a decision changes, a stored measurement of the old family is dropped as unmeasured until `scripts/measure-font-replacements.mjs` is run again.\n\n1. **Licensed, non-free fonts are never bundled or embedded.** This covers Aptos, Calibri, Cambria, Segoe UI, Georgia, Tahoma, Grandview, Seaford, Tenorite, Consolas, Times New Roman, Arial, Courier New and the Windows script fonts. Rendering uses the family's designated open replacement instead:\n - **Metric-compatible** is the goal, used where a replacement exists and measures identical: Calibri\u2192Carlito, Arial\u2192Arimo, Times New Roman\u2192Tinos, Courier New\u2192Cousine and Georgia\u2192Gelasio. A metric row needs an upstream statement and a measurement in all four styles, with a mean width difference below 0.1% and no corpus string more than 0.3% off. Georgia\u2192Gelasio passes only with Gelasio shaped with its `liga` and `clig` features off (the row lists them as `disabledFeatures`): with default features Gelasio ligates fi, fl, ffi and ffl, which Georgia does not, and runs differ by up to 1.02%. With them off every one of the 300 corpus strings matches in all four styles (mean and maximum below 0.01%). opf-render turns those features off in measurement and in SVG, so a renderer that does not is visual against Georgia.\n - **Size adjustment** (RR-38): a visual replacement whose advances and glyphs are far from the real font's can carry `sizeAdjust`, a preview-only font-size multiplier. A renderer scales the replacement's size by it when it measures and when it draws, so lines have the length PowerPoint's have; core's composed geometry, the exported sizes and every PPTX are unchanged. Arabic Typesetting\u2192Noto Naskh Arabic is 0.64 (the real font's advances are 0.643 of the replacement's over the Arabic corpus; its ink height is 0.71, so glyphs draw about 10 percent smaller than PowerPoint's). It applies only when the face drawn is the row's replacement. `lineAscent` and `lineAscentMixed` (em; Arabic Typesetting 0.70 and 0.78, measured in a native PowerPoint 365 probe against the font's hhea ascent of 0.701) say where PowerPoint puts the baseline below the top of a line box, for a line in the real font alone and for a line that also holds other fonts; a renderer that puts baselines one em below the line top moves such runs up by `1 - lineAscent` em.\n - **Alternates** are tried, in order, when the declared replacement's pack is not loaded. An alternate is always reported as visual, including on a metric row.\n - **Aptos family:** Aptos\u2192Intos, Aptos Display\u2192Intos Display, Aptos Narrow\u2192Intos Narrow and Aptos Serif\u2192Intos Serif are metric: 0.000% mean and maximum against Aptos 2.01 in all four styles, with equal vertical metrics.\n - **Otherwise the closest measured open face**, marked visual: a documented fallback and a known layout-fidelity gap until a metric-compatible replacement exists. For example, Segoe UI\u2192Red Hat Display measures a 1.72% mean width difference.\n2. **The PPTX always names the chosen family, and no font file is included.** The exporter writes `Aptos` when the document chose Aptos. PowerPoint resolves standard fonts on the viewer's machine: those shipped with Office, Windows or macOS, and Microsoft 365 cloud fonts. The replacement never reaches the package (opf-pptx `test/export-chosen-fonts.mjs`).\n3. **Openly licensed fonts render as themselves.** Examples are Roboto, Carlito and the Noto script families. They can reach a PPTX only through an explicit embed path (FF-13), never by default.\n4. **Families that are proprietary and non-standard, or missing from the table,** keep their name in the PPTX. `fontAvailabilityDiagnostics()` reports that viewers may lack them. It also flags Microsoft 365 cloud-only fonts (such as Aptos) and families that ship only in an optional Windows language feature.\n\n### Which faces are available\n\nopf-render ships only fonts that it already pins and hash-verifies:\n- **Base pack:** Roboto and Roboto Mono.\n- **Office pack:** Carlito, Caladea, Arimo, Tinos, Cousine and Gelasio.\n- **Office pack, Aptos family:** Intos, Intos Display, Intos Narrow and Intos Serif (OFL-1.1, vendored in opf-render at a pinned commit, unmodified from the upstream files). A registry built without them (for example the base pack alone) falls back to the alternates Roboto and Carlito, reported as visual.\n- **Optional `scripts` pack (FF-19):** Noto for non-Latin scripts and CJK.\n\nSome replacements are open families that no renderer pack ships yet, such as Red Hat Display for Segoe UI and Red Hat Text for Tahoma. For these, the renderer tries the declared replacement first, then the alternates. The last alternate is the best measured bundled face, so previews stay deterministic without any extra download. `registry.substitutions` records which face was used and its tier. The proposed `catalog` pack (21 OFL `@expo-google-fonts` packages, listed in the opf-render PR) would make the declared replacements and the open catalog families available. Downloading it needs approval, and it is not part of this change.\n\n| Environment | Open catalog families | Proprietary families | When the real font is required |\n| --- | --- | --- | --- |\n| Local Node, cloud or serverless (`prepareNodeFonts({pack: 'office', substitutionPolicy: 'visual'})`) | Exact when a pack ships them (Roboto, Carlito, \u2026, Noto with `scripts`) | Metric replacement: identical widths. Visual replacement: approximate, reported in `registry.substitutions` with the measured delta | Supply licensed files with `prepareNodeFonts({faces: [{path, family, weight, italic}]})`; they resolve as exact faces |\n| Strict mode (`substitutionPolicy: 'metric'`) | Exact | Metric replacements only | `font-unavailable` names the license class, the declared replacement and tier, the pack, and the caller hook. There is never a silent wrong-metric fallback |\n| Browser (`loadBrowserFontRegistry`) | Exact when the host serves the pack files | Same replacement rules | Same hook: pass the caller's own faces |\n| PowerPoint (exported PPTX) | Named; the viewer needs the font or substitutes | The selected name, never the replacement: the real font on Office, Windows or macOS, or through Microsoft 365 cloud fonts | Named; the viewer substitutes |\n\n**Aptos, the default scheme.** Aptos is a Microsoft 365 cloud font and is not redistributable. Under the owner policy of 2026-09-29 it previews with Intos, an OFL font whose advance widths, kerning and vertical metrics equal Aptos 2.01: 0.000% mean and maximum width difference over the 300-string corpus in regular, bold, italic and bold italic, for Aptos, Aptos Display, Aptos Narrow and Aptos Serif (Aptos Serif measured from Microsoft's standalone Aptos Fonts download, the others from the Microsoft 365 cloud fonts). Line breaks, line heights and text sizes therefore agree with Aptos. The letter shapes are Intos's own (Inter-derived, Gelasio-derived for the serif), not Aptos's. The exported PPTX still names Aptos, Aptos Display, Aptos Narrow or Aptos Serif, and no Aptos file is bundled or embedded. Deployments that hold an Aptos license can pass the real files through `faces` for exact previews.\n\nIntos ships in opf-render's default office pack (`prepareNodeFonts({pack: 'office'})`, `loadOfficeFontRegistry()`), about 12 MB of font files, so the default metric policy previews the Aptos family without asking for visual mode. Without those faces, previews fall back to the alternates Roboto and Carlito, marked visual. Like the open families, Intos is an `embed: \"used\"` face: `registry.embeddedFonts` stays the 33 eager npm faces, `prepareNodeFonts().options.embeddedFonts` supplies it, and a standalone SVG embeds only the Intos faces its text draws (an Aptos slide: Intos regular and Intos Display bold, 14.7 MB with the eager faces, against 20.7 MB with all eight styles). Intos is a single-maintainer project started in September 2026, so it is pinned by commit and SHA-256 and the previous replacements stay as alternates. The pptx.gallery parity scoreboard's `fontResolution` check counts the Aptos family as perfect because the replacement is metric-compatible.\n\n**Browser hosts load the vendored faces on demand.** The eager list is what a host puts in one `fonts.json` (12.6 MB); the vendored faces (Intos and the open families, `registry.lazyFonts`, 51 faces) would add 19.4 MB, so they ship as separate hash-pinned files at their package-relative paths (`fonts/intos/...`, `fonts/<family>/...`) and load through `loadBrowserFontRegistry(faces, {lazyFontsBaseUrl})`. `await registry.ensureLazyFonts(presentation)` fetches and verifies only the faces the document draws (renderer 0.11.5, face level: a plain Aptos deck needs Intos Display Bold and Intos Regular, 2 files, 1.5 MB; an italic or bold run adds one face; before 0.11.5 it was every face of the resolved families, 8 files, 5.9 MB), then adds them to the document and the registry together, so the editor never measures with a face it paints as a fallback. The gallery commits only a pinned manifest, `lazy-fonts.json` (`scripts/gallery-lazy-fonts.mjs`, written by `build-registry-gallery-editor` from the published renderer's manifest when the pinned editor example calls `ensureLazyFonts`, and copied by `prepare-gallery-editor`): exact renderer version, SPDX license, license-file hash and every face's SHA-256, no bytes. The gallery's own build copies the faces from the pinned renderer package's `fonts/` directory into an untracked path, verifying each hash, the way it does for script fonts. The local editor demo (`build-editor-demo`, through `scripts/emit-lazy-fonts.mjs`) copies the files beside the page instead. The editor playground calls `ensureLazyFonts` when a document needs them. `node scripts/test-editor-lazy-fonts.mjs` drives the built playground in Chromium.\n\nThe [Windows reference-font advance study](evidence/shared-metric-native-anchor/font-study-comparison.json) is exploratory source-checkpoint evidence, not an additional compatibility certification. It measures 1,024 cases across regular/bold Calibri, Arial, Times New Roman and Courier New, recording local reference-file versions/hashes and native font-slot names. Disabling optional ligatures and rounding base glyph advances to eighth-point steps predicts 949 observations within 0.02pt; 75 outliers remain, including combining marks, Arabic and Calibri kerning. Office theme tokens and per-glyph fallback are not resolved to exact native files by these name properties. No runtime provider or open-font mapping changes from this hypothesis, and no reference font is redistributed.\n\nThe composition API accepts a `textMeasurement` provider. A provider resolves font faces and returns actual text widths; callers pass the same provider to pagination (as `{ fonts: { textMeasurement } }`), editor geometry, SVG rendering, and PPTX export. Without one, the existing deterministic character-width estimate remains available.\n\nRenderer 0.8.0 publishes `prepareNodeFonts` from `/fonts-node`. Its returned `options` combine the same registry measurement, embedded SVG fonts and explicit raster files, with system/bundled fallback disabled for raster calls. Pass these options to pagination as `{ fonts: options }` (deck-level verbs take the fonts handle under `fonts`), and to editor geometry, SVG, PPTX and PNG/PDF export directly. `pack: 'base'` is the default for authored Roboto decks; `pack: 'office'` adds the six Office substitute families and retains metric policy unless visual substitution is explicitly requested. `registry.substitutions` records actual substitutions; the helper does not rewrite the authored document or add native embedding.\n\nRenderer 0.8.0 groups static files by their OpenType preferred family while retaining legacy family names and explicit custom namespaces. `Roboto` requests at 500/600/800 now select the actual Medium/SemiBold/ExtraBold files instead of nearby 400/700 faces. Optional `TextStyle.fontFace` carries the physical legacy family and native bold/italic flags independently of CSS numeric weight: SemiBold/ExtraBold are regular within their legacy families. The converter consumes this metadata; providers without it retain their prior behavior. Nine actual base faces pass metadata/measurement/outline checks and offline Chromium advances on Node 20/24. Seven payload slides cover serialized native selectors, deterministic output and source/reimport. These checks do not establish native Office paint, embedding or broader script coverage; see the [Mac candidate evidence](evidence/mac-font-variants/README.md).\n\nRenderer 0.8.0 uses adjacent SVG spans when no measurement provider is supplied. This closes the visible gaps caused by estimated fragment widths while retaining estimated line breaks and all run text/style/source offsets. Supplied providers and accepted placements still use exact fragment origins. Measured SVG requests geometric precision; accepted outline placements also constrain horizontal advances with `textLength`/`spacingAndGlyphs` to avoid browser quantization drift. This can scale glyphs horizontally while retaining nominal font size and baseline. Width-only providers do not receive that constraint, and constrained widths do not certify raw font-metric equivalence. The compatible editor supports both forms. This is a spacing improvement, not evidence that unmeasured wrapping or glyph coverage is accurate; see [candidate evidence](evidence/mac-rich-flow/README.md).\n\nBoth Node loaders verify exact package versions, 33 font-file hashes and eight license-notice hashes against the immutable `BUNDLED_FONT_MANIFEST` exported from `/fonts-node`. The office loader also verifies the vendored faces: the Carlito files, the 35 open-family files and the 16 Intos files, with the hashes of their licenses and, for Intos, its provenance notice. Missing/modified resources reject with actionable errors. Default raster loading now includes all nine base faces instead of omitting Roboto semibold, italic and bold italic. Font files must remain available and unchanged between preparation and raster export. Browser loading, actual glyph coverage, variant naming, rich spacing, and native compatibility remain separate requirements. These APIs are available in [renderer 0.8.0](https://github.com/OpenPresentation/opf-render/releases/tag/opf-render-v0.8.0), published against core 0.10.0 with Node 24. Prepared HarfBuzz shaping and variable-instance work remain separate drafts.\n\nThe renderer's optional font registry uses [Fontkit](https://github.com/foliojs/fontkit) to shape text and measure glyph advances from local font bytes. It does not discover system fonts or fetch fonts. The Node helper loads the renderer's bundled Roboto and Roboto Mono faces:\n\n```js\nimport { loadBundledFontRegistry } from '@openpresentation/opf-render/fonts-node';\nimport { renderSvg, svgToPng } from '@openpresentation/opf-render';\nimport { paginate } from '@openpresentation/opf/pagination';\nimport { toPptx } from '@openpresentation/opf-pptx';\n\nconst registry = await loadBundledFontRegistry();\nconst options = { textMeasurement: registry.textMeasurement };\n// Use design.fontScheme: 'roboto', or supply the document's actual font files.\nconst { presentation } = paginate(deck, { fonts: options });\nconst svg = renderSvg(presentation, {\n ...options,\n embeddedFonts: registry.embeddedFonts,\n});\nconst png = await svgToPng(svg, {\n fontFiles: registry.fontFiles,\n useBundledFonts: false,\n loadSystemFonts: false,\n});\nconst pptx = await toPptx(presentation, options);\n```\n\n`createFontRegistry` from `@openpresentation/opf-render/fonts` accepts `{data: Uint8Array, weight, italic?, family?, postscriptName?, license?}` entries in Node or the browser. Weights are explicit, with 400 as the default. Supply each style that the document uses. Missing font families and unsupported glyphs fail with `OPFFontError`, including the source path where available. Collection fonts require a `postscriptName` selecting one face.\n\nAliases and fallback families are explicit choices:\n\n```js\nconst registry = createFontRegistry(faces, {\n aliases: { Aptos: 'Roboto', 'Aptos Display': 'Roboto' },\n fallbackFamily: 'Roboto',\n});\nconsole.log(registry.substitutions);\nregistry.clearSubstitutions(); // Start a fresh render's diagnostic collection.\n```\n\nAn available exact family takes precedence over aliases. The registry resolves a requested weight to the closest supplied weight, reports the substitution, and makes the resolved style available to rendering. Missing italic/upright styles fail instead of synthesizing an unmeasured style. `strictGlyphs: false` is an explicit escape hatch for hosts with their own glyph-fallback policy; it is unsuitable for fidelity verification.\n\nSVG embeds supplied fonts using data URIs and includes supplied license notices as metadata. The bundled loader carries the fonts' SIL Open Font License notices. For PNG/PDF, pass the same font files to the rasterizer; its native font loader does not depend on browser CSS font loading. In a browser, wait for `document.fonts.ready` before measuring or taking a screenshot. The editor playground loads and embeds bundled fonts and displays substitutions.\n\n`svgToPdf` in renderers up to 0.11.9 was image-only: each slide was rasterized and embedded as a PNG on a PDF page. From opf-render 0.12.0 (opf-render#90, [roadmap](plans/pdf-export.md)) the default `mode: \"vector\"` writes PDF text objects in embedded TrueType subsets of the fonts you supply or the bundled open pack (the same files the PNG preview uses; system fonts are rejected, a face whose OS/2 `fsType` forbids embedding is never embedded, the report names requested and resolved faces), with `ToUnicode` maps and `/ActualText` where the glyph map cannot give the text, vector shapes, gradients and images, and no second layout pass. `mode: \"raster\"` keeps the image-per-slide output as an explicit compatibility mode. Extraction was checked in pdf.js, PDFium and poppler on Latin, CJK, right-to-left and Indic samples and all 805 example slides; PDFium misreads some Thai and Burmese marks, as it does in Chrome's own PDFs. No PDF/UA or PDF/A claim.\n\n## Office compatibility pack\n\n`loadOfficeFontRegistry` from `@openpresentation/opf-render/fonts-node` supplies regular, bold, italic, and bold italic faces of Carlito, Caladea, Arimo, Tinos, Cousine, and Gelasio, plus the base Roboto pack. Package versions are pinned and each face carries its distribution's license notice. `includeBaseFonts: false` omits Roboto. Loading never installs fonts into the operating system or downloads fonts at render time.\n\n```js\nconst registry = await loadOfficeFontRegistry({\n substitutionPolicy: 'metric', // Default for this loader; no visual fallback.\n});\nregistry.resolveFont({fontFamily: 'Calibri', fontWeight: 400});\n// requestedFamily: Calibri, resolvedFamily: Carlito, compatibility: metric\n```\n\n`createFontRegistry` defaults to `substitutionPolicy: 'none'`. Policies are `none`, `metric`, and `visual`; visual permits both curated tiers. An explicit `fallbackFamily` is a separate, reported `generic` fallback. Aliases are explicit visual substitutions and never establish metric compatibility. `resolveFont` reports exact resolutions as well; `substitutions` only collects changes. Resolution records include requested/resolved weights, italic, source path, and supporting upstream information where available.\n\n| Requested family | Bundled substitute | Current automatic tier |\n| --- | --- | --- |\n| Calibri | Carlito | Metric intent, standard 400/700 styles |\n| Cambria | Caladea | Visual: advances differ from Cambria 6.99 by a mean of 2.7% (FF-31 measurement). Metric-mode registries still use it, reported as visual (`metricModeFallback`) |\n| Arial | Arimo | Metric, standard 400/700 styles |\n| Times New Roman | Tinos | Metric, standard 400/700 styles |\n| Courier New | Cousine | Metric, standard 400/700 styles |\n| Georgia | Gelasio | Metric with `liga` and `clig` off (`disabledFeatures`, applied by opf-render): advances identical on all 300 corpus strings in four styles. With default features, ligature runs differ by up to 1.02% |\n| Calibri Light | Carlito | Visual: the bundle has no Carlito Light face |\n| Aptos, Aptos Display, Aptos Narrow, Aptos Serif | Intos, Intos Display, Intos Narrow, Intos Serif (office pack) | Metric: 0.000% mean and maximum against Aptos 2.01, all four styles; Roboto and Carlito are visual alternates |\n\nUpstream evidence: [Carlito](https://github.com/googlefonts/carlito), [Fontconfig mappings](https://chromium.googlesource.com/external/fontconfig/+/refs/heads/main/conf.d/30-metric-aliases.conf), [Arimo](https://github.com/google/fonts/blob/main/ofl/arimo/DESCRIPTION.en_us.html), [Tinos](https://github.com/google/fonts/blob/main/ofl/tinos/DESCRIPTION.en_us.html), [Cousine](https://github.com/google/fonts/blob/main/ofl/cousine/DESCRIPTION.en_us.html), and [Gelasio](https://github.com/SorkinType/Gelasio). Metric classification describes compatibility intent within the stated style scope, not universal identical output. Missing matching weights cannot silently qualify for the metric tier.\n\nRR-17: Liberation Sans, Serif and Mono are not bundled (Reserved Font Name, about 4.4 MB); a document that names them previews with Arimo, Tinos and Cousine, the Croscore faces Liberation 2 is built from (metric, 0.0000% in four styles). Each Latin replacement has a per-family qualification (`scripts/qualify-latin-fonts.mjs`) and a fixture in every host; see the [font tracker](programs/font-fidelity-everywhere/font-tracker.md).\n\nThe exported `FONT_COMPATIBILITY` list also contains optional visual candidates and CJK families. Listing a candidate does not bundle it or imply complete character coverage. Liberation Sans Narrow is a separate legacy distribution with a different license history; it is not part of this bundle. Wingdings, Webdings, and Symbol require character mapping before substitution; an ordinary fallback fails with `font-encoding-required`. Cambria Math previews with STIX Two Math and Segoe UI Emoji with Noto Color Emoji once opf-render's optional math and emoji packs are loaded (FF-45, [special families: emoji and math](programs/font-fidelity-everywhere/special-families-emoji-math.md)); without the pack a renderer reports `font-unavailable` naming it, like any other routed family (the former `math-font-required` failure is gone). OPF has no equation model: a Cambria Math run is text drawn per character in a math face, not MATH-table layout.\n\nDrawingML tokens such as `+mn-lt` resolve through the registry's explicit `themeFonts` option before substitution. Supply concrete `majorLatin`, `minorLatin`, and, where used, `majorEastAsia`, `minorEastAsia`, `majorComplexScript`, or `minorComplexScript` families. Missing theme mappings fail. This helper does not yet extract theme font records or embedded fonts from imported PPTX files.\n\n### Measured results and experimental fonts\n\n`node --import ./scripts/register-local-opf.mjs scripts/test-office-fonts.mjs --system` compares the bundle to reference fonts already installed in macOS's Supplemental directory. It does not redistribute reference fonts. The report records source-file hashes and individual shaped widths. Across four samples and four styles, Arimo/Arial, Tinos/Times New Roman, and Cousine/Courier New matched exactly on 48 runs. Gelasio/Georgia differed on ligature-containing runs, with a maximum difference of 2.0125%. Individual basic-Latin advances matched; disabling optional ligatures removed the tested difference. Until feature handling is consistent across outputs, the policy conservatively labels Gelasio approximate. Calibri and Cambria reference fonts were not available for this comparison.\n\nAkasia, assessed earlier ([v0.0.2 open-file assessment](evidence/akasia-assessment/README.md)), is dropped: its repository is no longer available, and Intos replaces it. `EXPERIMENTAL_FONT_CANDIDATES` now records Microsoft's Selawik, measured for Segoe UI on 2026-09-29 and rejected: 0.16% mean and 2.5% maximum in regular, no italic faces, 349 code points, lowercase 4.8% shorter. The acceptance rules for replacement fonts are in the [licensing table](programs/font-fidelity-everywhere/font-licensing.md#replacement-font-acceptance-rules).\n\nAn original OPF font project is technically feasible: independently designed or suitably open-licensed glyph outlines can be fitted to target advance widths, placement, vertical metrics, and shaping behavior. A successful font needs a reproducible source build, provenance, style/coverage tests, visual review, and cross-renderer conformance. Matching bounding boxes alone is insufficient: [OpenType horizontal metrics](https://learn.microsoft.com/en-us/typography/opentype/spec/hmtx) and [glyph positioning](https://learn.microsoft.com/en-us/typography/opentype/spec/gpos) jointly control text placement. Universal pixel identity across rasterizers is not the acceptance criterion; measured layout preservation over an explicit test matrix is.\n\n## Verification and remaining work\n\nScript fonts (CJK, Arabic, Hebrew, Indic, Thai, Khmer, Myanmar and the rest) have their own shaping corpora and per-family qualification (FF-44, RR-17): see [script-corpora.md](programs/font-fidelity-everywhere/script-corpora.md). It records, per script, glyph coverage, fontkit against HarfBuzz and Chromium, the installed originals measured in place, and the known limits (fontkit has no Myanmar shaper; the PNG path of resvg-js mis-shapes the Indic scripts, Thai, Lao, Khmer and Myanmar).\n\n`pnpm test:fonts` checks that editor and SVG geometry match and that every native PPTX text box has the same coordinates and measured line breaks. With opf-pptx FF-31 (opf-pptx#63), export names the chosen family, not the preview substitute. It writes artifacts to `artifacts/fonts/`. A real-browser check of the same Roboto run measured 324.032 pixels versus the font engine's 324.170 pixels at 25 pixels, a difference of 0.138 pixels. These are measured tolerances, not a promise of pixel identity.\n\nPPTX records the chosen font family (FF-31); it never records a preview replacement and never embeds a proprietary font binary. PowerPoint still needs those fonts installed, through Office, the OS or Microsoft 365 cloud fonts, or it substitutes them. Line height remains the shared 1.22 multiplier, rather than a complete ascent/descent model. Rich-text font overrides, mixed-script fallback and bidi layout, specialized payload internals, and native font embedding remain active fidelity work. Passing a width provider does not remove those limits.\n"
109
+ "markdown": "# Measured fonts and reproducible previews\n\nFor the starter set and delivery priorities, see the [font roadmap](plans/font-roadmap.md).\n\n## Font policy (FF-31)\n\nOPF keeps one machine-readable font policy table, [`spec/reference/font-policy.json`](../spec/reference/font-policy.json). Core exports it as `FONT_POLICY`, `fontPolicyFor()` and `applyFontPolicyDecisions()` from `@openpresentation/opf` or `@openpresentation/opf/font-policy`. Each row gives a family's license class, where viewers get it, whether OPF may ever embed it, and its preview replacement with a measured width difference. It also lists alternates, ending where possible with a face that already ships with opf-render. The [licensing table](programs/font-fidelity-everywhere/font-licensing.md) lists all 153 rows. [`font-policy.schema.json`](../spec/reference/font-policy.schema.json) is its JSON Schema. The [measurement evidence](evidence/font-replacements-20260923/README.md) explains how each replacement was chosen.\n\n**The policy in brief (owner decisions, 2026-09-29).** This is the canonical statement; the sibling repositories link here.\n\n1. **The user's font selection is the source of truth.** The user picks a font, for example Aptos or Calibri through a font scheme. That name is what the document, the theme and the PPTX carry.\n2. **License-restricted (proprietary) fonts are never bundled or embedded,** so previews cannot draw them. Openly licensed fonts such as Carlito and Roboto are bundled, and \"licensed\" below always means license-restricted. Live previews, SVG, the editor and gallery thumbnails use an open look-alike instead. The goal is a replacement that looks similar and is metric-compatible (same advance widths and line metrics), so text occupies the same size on screen and wraps as it does in PowerPoint. Calibri\u2192Carlito is the model.\n3. **Where no metric-compatible open replacement exists yet,** a visual-only look-alike is a documented fallback. It is reported as visual and is a known layout-fidelity gap to close, not the intended end state. The parity scoreboard counts it as near, not perfect. The Aptos family is no longer such a case: Intos previews it as metric.\n4. **PPTX export always writes the selected font name,** for example `typeface=\"Aptos\"` in the theme and in runs, never the replacement. PowerPoint then opens the file and shows the actual font, installed or as a Microsoft 365 cloud font.\n5. **Only open fonts may be embedded,** through the explicit embed path (FF-13).\n6. **Font files are bundled, never hotlinked.** Fonts, Google Fonts included, ship as pinned files (an exact npm version, or a vendored file with a recorded sha256) and are never loaded at runtime from a font CDN. That protects visitor privacy (a German court held the Google Fonts CDN a GDPR violation in 2022) and keeps previews offline-capable and audits reproducible. Build-time self-hosting such as `next/font/google` is not a hotlink but is not pinned, so use `next/font/local` with pinned files. Guards fail on CDN references in every repository.\n7. **Every bundled face records a verified permissive license.** Allowed: exactly OFL-1.1, Apache-2.0, MIT and UFL-1.0; not GPL, LGPL, AGPL, proprietary or unclear public-domain fonts. The record has the SPDX id, any Reserved Font Name the copyright block declares, the source URL, package@version and sha256, all checked against the LICENSE file the font ships with. A modified version (subset, instance, conversion) may not use a Reserved Font Name in its name: a family whose served name contains its reserved name (Carlito, Raleway) is bundled only as the unmodified upstream file, while one that reserves another name (Noto Sans JP reserves \"Source\") may be modified. Details and enforcement: [Font files: bundling and licenses](programs/font-fidelity-everywhere/font-licensing.md#font-files-bundling-and-licenses).\n\n**Release status.** Point 4 is published: opf-pptx 0.10.0 and later (current 0.13.2) write the selected name into the PPTX, and the editor and pptx.gallery builds that depend on the release-plan set export it. opf-pptx 0.9.1 and earlier wrote the substitute.\n\n**Provisional owner decisions (provisional, owner may revise).** Three choices about which open face stands in for a family are pending with the owner. The policy above is settled; only these replacement picks are provisional. Root resolved them provisionally with the recommended defaults:\n\n| Decision | Families | Replacement |\n| --- | --- | --- |\n| `aptos-preview` | Aptos (the default `aptos` scheme) | Intos (metric, owner policy 2026-09-29; Roboto and Carlito are alternates) |\n| `segoe-ui-preview` | Segoe UI, Semibold, Light and Semilight | Red Hat Display (visual) |\n| `cambria-tier` | Cambria | Caladea, reclassified from metric to visual. Its `metricModeFallback` keeps metric-mode registries previewing Cambria with Caladea, reported as visual, as they did before FF-31. |\n\nAll three live in one block, `provisionalDecisions`, at the top of the JSON. The rows that follow a decision carry no replacement family of their own. A change of decision is therefore a one-line edit. When a decision changes, a stored measurement of the old family is dropped as unmeasured until `scripts/measure-font-replacements.mjs` is run again.\n\n1. **Licensed, non-free fonts are never bundled or embedded.** This covers Aptos, Calibri, Cambria, Segoe UI, Georgia, Tahoma, Grandview, Seaford, Tenorite, Consolas, Times New Roman, Arial, Courier New and the Windows script fonts. Rendering uses the family's designated open replacement instead:\n - **Metric-compatible** is the goal, used where a replacement exists and measures identical: Calibri\u2192Carlito, Arial\u2192Arimo, Times New Roman\u2192Tinos, Courier New\u2192Cousine and Georgia\u2192Gelasio. A metric row needs an upstream statement and a measurement in all four styles, with a mean width difference below 0.1% and no corpus string more than 0.3% off. Georgia\u2192Gelasio passes only with Gelasio shaped with its `liga` and `clig` features off (the row lists them as `disabledFeatures`): with default features Gelasio ligates fi, fl, ffi and ffl, which Georgia does not, and runs differ by up to 1.02%. With them off every one of the 300 corpus strings matches in all four styles (mean and maximum below 0.01%). opf-render turns those features off in measurement and in SVG, so a renderer that does not is visual against Georgia.\n - **Size adjustment** (RR-38): a visual replacement whose advances and glyphs are far from the real font's can carry `sizeAdjust`, a preview-only font-size multiplier. A renderer scales the replacement's size by it when it measures and when it draws, so lines have the length PowerPoint's have; core's composed geometry, the exported sizes and every PPTX are unchanged. Arabic Typesetting\u2192Noto Naskh Arabic is 0.64 (the real font's advances are 0.643 of the replacement's over the Arabic corpus; its ink height is 0.71, so glyphs draw about 10 percent smaller than PowerPoint's). It applies only when the face drawn is the row's replacement. `lineAscent` and `lineAscentMixed` (em; Arabic Typesetting 0.70 and 0.78, measured in a native PowerPoint 365 probe against the font's hhea ascent of 0.701) say where PowerPoint puts the baseline below the top of a line box, for a line in the real font alone and for a line that also holds other fonts; a renderer that puts baselines one em below the line top moves such runs up by `1 - lineAscent` em.\n - **Alternates** are tried, in order, when the declared replacement's pack is not loaded. An alternate is always reported as visual, including on a metric row.\n - **Aptos family:** Aptos\u2192Intos, Aptos Display\u2192Intos Display, Aptos Narrow\u2192Intos Narrow and Aptos Serif\u2192Intos Serif are metric: 0.000% mean and maximum against Aptos 2.01 in all four styles, with equal vertical metrics.\n - **Otherwise the closest measured open face**, marked visual: a documented fallback and a known layout-fidelity gap until a metric-compatible replacement exists. For example, Segoe UI\u2192Red Hat Display measures a 1.72% mean width difference.\n2. **The PPTX always names the chosen family, and no font file is included.** The exporter writes `Aptos` when the document chose Aptos. PowerPoint resolves standard fonts on the viewer's machine: those shipped with Office, Windows or macOS, and Microsoft 365 cloud fonts. The replacement never reaches the package (opf-pptx `test/export-chosen-fonts.mjs`).\n3. **Openly licensed fonts render as themselves.** Examples are Roboto, Carlito and the Noto script families. They can reach a PPTX only through an explicit embed path (FF-13), never by default.\n4. **Families that are proprietary and non-standard, or missing from the table,** keep their name in the PPTX. `fontAvailabilityDiagnostics()` reports that viewers may lack them. It also flags Microsoft 365 cloud-only fonts (such as Aptos) and families that ship only in an optional Windows language feature.\n\n### Which faces are available\n\nopf-render ships only fonts that it already pins and hash-verifies:\n- **Base pack:** Roboto and Roboto Mono.\n- **Office pack:** Carlito, Caladea, Arimo, Tinos, Cousine and Gelasio.\n- **Office pack, Aptos family:** Intos, Intos Display, Intos Narrow and Intos Serif (OFL-1.1, vendored in opf-render at a pinned commit, unmodified from the upstream files). A registry built without them (for example the base pack alone) falls back to the alternates Roboto and Carlito, reported as visual.\n- **Optional `scripts` pack (FF-19):** Noto for non-Latin scripts and CJK.\n\nSome replacements are open families that no renderer pack ships yet, such as Red Hat Display for Segoe UI and Red Hat Text for Tahoma. For these, the renderer tries the declared replacement first, then the alternates. The last alternate is the best measured bundled face, so previews stay deterministic without any extra download. `registry.substitutions` records which face was used and its tier. The proposed `catalog` pack (21 OFL `@expo-google-fonts` packages, listed in the opf-render PR) would make the declared replacements and the open catalog families available. Downloading it needs approval, and it is not part of this change.\n\n| Environment | Open catalog families | Proprietary families | When the real font is required |\n| --- | --- | --- | --- |\n| Local Node, cloud or serverless (`loadFonts({pack: 'office', substitutionPolicy: 'visual'})` from `/fonts-node`) | Exact when a pack ships them (Roboto, Carlito, \u2026, Noto with `scripts`) | Metric replacement: identical widths. Visual replacement: approximate, reported in `fonts.substitutions` with the measured delta | Supply licensed files with `loadFonts({faces: [{path, family, weight, italic}]})`; they resolve as exact faces |\n| Strict mode (`substitutionPolicy: 'metric'`) | Exact | Metric replacements only | `font-unavailable` names the license class, the declared replacement and tier, the pack, and the caller hook. There is never a silent wrong-metric fallback |\n| Browser (`loadFonts` from `/fonts-browser`) | Exact when the host serves the pack files | Same replacement rules | Same hook: pass the caller's own faces |\n| PowerPoint (exported PPTX) | Named; the viewer needs the font or substitutes | The selected name, never the replacement: the real font on Office, Windows or macOS, or through Microsoft 365 cloud fonts | Named; the viewer substitutes |\n\n**Aptos, the default scheme.** Aptos is a Microsoft 365 cloud font and is not redistributable. Under the owner policy of 2026-09-29 it previews with Intos, an OFL font whose advance widths, kerning and vertical metrics equal Aptos 2.01: 0.000% mean and maximum width difference over the 300-string corpus in regular, bold, italic and bold italic, for Aptos, Aptos Display, Aptos Narrow and Aptos Serif (Aptos Serif measured from Microsoft's standalone Aptos Fonts download, the others from the Microsoft 365 cloud fonts). Line breaks, line heights and text sizes therefore agree with Aptos. The letter shapes are Intos's own (Inter-derived, Gelasio-derived for the serif), not Aptos's. The exported PPTX still names Aptos, Aptos Display, Aptos Narrow or Aptos Serif, and no Aptos file is bundled or embedded. Deployments that hold an Aptos license can pass the real files through `faces` for exact previews.\n\nIntos ships in opf-render's default office pack (`loadFonts({pack: 'office'})`), about 12 MB of font files, so the default metric policy previews the Aptos family without asking for visual mode. Without those faces, previews fall back to the alternates Roboto and Carlito, marked visual. Like the open families, Intos is an `embed: \"used\"` face: `registry.embeddedFonts` stays the 33 eager npm faces, the handle's `embeddedFonts` supplies it, and a standalone SVG embeds only the Intos faces its text draws (an Aptos slide: Intos regular and Intos Display bold, 14.7 MB with the eager faces, against 20.7 MB with all eight styles). Intos is a single-maintainer project started in September 2026, so it is pinned by commit and SHA-256 and the previous replacements stay as alternates. The pptx.gallery parity scoreboard's `fontResolution` check counts the Aptos family as perfect because the replacement is metric-compatible.\n\n**Browser hosts load the vendored faces on demand.** The eager list is what a host puts in one `fonts.json` (12.6 MB); the vendored faces (Intos and the open families, `registry.lazyFonts`, 51 faces) would add 19.4 MB, so they ship as separate hash-pinned files at their package-relative paths (`fonts/intos/...`, `fonts/<family>/...`) and load through `loadFonts({faces, lazyFontsBaseUrl})` from `/fonts-browser`. `await fonts.ensure(presentation)` (the registry's `ensureLazyFonts` and `ensureScripts` are the lower-level calls) fetches and verifies only the faces the document draws (renderer 0.11.5, face level: a plain Aptos deck needs Intos Display Bold and Intos Regular, 2 files, 1.5 MB; an italic or bold run adds one face; before 0.11.5 it was every face of the resolved families, 8 files, 5.9 MB), then adds them to the document and the registry together, so the editor never measures with a face it paints as a fallback. The gallery commits only a pinned manifest, `lazy-fonts.json` (`scripts/gallery-lazy-fonts.mjs`, written by `build-registry-gallery-editor` from the published renderer's manifest when the pinned editor example sets `lazyFontsBaseUrl`, and copied by `prepare-gallery-editor`): exact renderer version, SPDX license, license-file hash and every face's SHA-256, no bytes. The gallery's own build copies the faces from the pinned renderer package's `fonts/` directory into an untracked path, verifying each hash, the way it does for script fonts. The local editor demo (`build-editor-demo`, through `scripts/emit-lazy-fonts.mjs`) copies the files beside the page instead. The editor playground calls `fonts.ensure(presentation)` when a document needs them. `node scripts/test-editor-lazy-fonts.mjs` drives the built playground in Chromium.\n\nThe [Windows reference-font advance study](evidence/shared-metric-native-anchor/font-study-comparison.json) is exploratory source-checkpoint evidence, not an additional compatibility certification. It measures 1,024 cases across regular/bold Calibri, Arial, Times New Roman and Courier New, recording local reference-file versions/hashes and native font-slot names. Disabling optional ligatures and rounding base glyph advances to eighth-point steps predicts 949 observations within 0.02pt; 75 outliers remain, including combining marks, Arabic and Calibri kerning. Office theme tokens and per-glyph fallback are not resolved to exact native files by these name properties. No runtime provider or open-font mapping changes from this hypothesis, and no reference font is redistributed.\n\nThe composition API accepts a `textMeasurement` provider. A provider resolves font faces and returns actual text widths; callers pass the same provider to pagination (as `{ fonts: { textMeasurement } }`), editor geometry, SVG rendering, and PPTX export. Without one, the existing deterministic character-width estimate remains available.\n\n`loadFonts(options)` from `/fonts-node` returns the fonts handle: the same registry measurement (`textMeasurement`), the faces to embed in SVG (`embeddedFonts`) and the explicit raster files (`fontFiles`), with system and bundled fallback disabled for raster calls (`useBundledFonts: false`, `loadSystemFonts: false`). Pass it as `{ fonts }` to every deck-level call: `paginate`, editor geometry, `renderSvg`, `renderSlideSvg`, `toPptx`, `svgToPng` and `svgToPdf`. `pack: 'base'` is the default for authored Roboto decks; `pack: 'office'` adds the six Office substitute families and retains metric policy unless visual substitution is explicitly requested. `fonts.substitutions` records actual substitutions; the loader does not rewrite the authored document or add native embedding.\n\nRenderer 0.8.0 groups static files by their OpenType preferred family while retaining legacy family names and explicit custom namespaces. `Roboto` requests at 500/600/800 now select the actual Medium/SemiBold/ExtraBold files instead of nearby 400/700 faces. Optional `TextStyle.fontFace` carries the physical legacy family and native bold/italic flags independently of CSS numeric weight: SemiBold/ExtraBold are regular within their legacy families. The converter consumes this metadata; providers without it retain their prior behavior. Nine actual base faces pass metadata/measurement/outline checks and offline Chromium advances on Node 20/24. Seven payload slides cover serialized native selectors, deterministic output and source/reimport. These checks do not establish native Office paint, embedding or broader script coverage; see the [Mac candidate evidence](evidence/mac-font-variants/README.md).\n\nRenderer 0.8.0 uses adjacent SVG spans when no measurement provider is supplied. This closes the visible gaps caused by estimated fragment widths while retaining estimated line breaks and all run text/style/source offsets. Supplied providers and accepted placements still use exact fragment origins. Measured SVG requests geometric precision; accepted outline placements also constrain horizontal advances with `textLength`/`spacingAndGlyphs` to avoid browser quantization drift. This can scale glyphs horizontally while retaining nominal font size and baseline. Width-only providers do not receive that constraint, and constrained widths do not certify raw font-metric equivalence. The compatible editor supports both forms. This is a spacing improvement, not evidence that unmeasured wrapping or glyph coverage is accurate; see [candidate evidence](evidence/mac-rich-flow/README.md).\n\nBoth Node loaders verify exact package versions, 33 font-file hashes and eight license-notice hashes against the immutable `BUNDLED_FONT_MANIFEST` exported from `/fonts-node`. The office loader also verifies the vendored faces: the Carlito files, the 35 open-family files and the 16 Intos files, with the hashes of their licenses and, for Intos, its provenance notice. Missing/modified resources reject with actionable errors. Default raster loading now includes all nine base faces instead of omitting Roboto semibold, italic and bold italic. Font files must remain available and unchanged between preparation and raster export. Browser loading, actual glyph coverage, variant naming, rich spacing, and native compatibility remain separate requirements. These APIs are available in [renderer 0.8.0](https://github.com/OpenPresentation/opf-render/releases/tag/opf-render-v0.8.0), published against core 0.10.0 with Node 24. Prepared HarfBuzz shaping and variable-instance work remain separate drafts.\n\nThe renderer's optional font registry uses [Fontkit](https://github.com/foliojs/fontkit) to shape text and measure glyph advances from local font bytes. It does not discover system fonts or fetch fonts. The Node loader loads the renderer's bundled Roboto and Roboto Mono faces:\n\n```js\nimport { loadFonts } from '@openpresentation/opf-render/fonts-node';\nimport { renderSlideSvg, svgToPng } from '@openpresentation/opf-render';\nimport { paginate } from '@openpresentation/opf/pagination';\nimport { toPptx } from '@openpresentation/opf-pptx';\n\nconst fonts = await loadFonts(); // pack: 'base', the bundled Roboto faces\n// Use design.fontScheme: 'roboto', or supply the document's actual font files.\nconst { presentation } = paginate(deck, { fonts });\nconst svg = renderSlideSvg(presentation, 0, { fonts }); // embeds the faces it draws\nconst png = await svgToPng(svg, { fonts }); // draws with fonts.fontFiles only\nconst pptx = await toPptx(presentation, { fonts });\n```\n\n`loadFonts({ faces })` (from `/fonts-node` or `/fonts-browser`) accepts `{data: Uint8Array, weight, italic?, family?, postscriptName?, license?}` entries in Node or the browser, and `{path}` entries in Node. Weights are explicit, with 400 as the default. Supply each style that the document uses. Missing font families and unsupported glyphs fail with `OPFFontError`, including the source path where available. Collection fonts require a `postscriptName` selecting one face.\n\nAliases and fallback families are explicit choices:\n\n```js\nconst fonts = await loadFonts({\n pack: 'none', faces,\n aliases: { Aptos: 'Roboto', 'Aptos Display': 'Roboto' },\n fallbackFamily: 'Roboto',\n});\nconsole.log(fonts.substitutions);\nfonts.registry.clearSubstitutions(); // Start a fresh render's diagnostic collection.\n```\n\nAn available exact family takes precedence over aliases. The registry resolves a requested weight to the closest supplied weight, reports the substitution, and makes the resolved style available to rendering. Missing italic/upright styles fail instead of synthesizing an unmeasured style. `strictGlyphs: false` is an explicit escape hatch for hosts with their own glyph-fallback policy; it is unsuitable for fidelity verification.\n\nSVG embeds supplied fonts using data URIs and includes supplied license notices as metadata. The bundled loader carries the fonts' SIL Open Font License notices. For PNG/PDF, pass the same font files to the rasterizer; its native font loader does not depend on browser CSS font loading. In a browser, wait for `document.fonts.ready` before measuring or taking a screenshot. The editor playground loads and embeds bundled fonts and displays substitutions.\n\n`svgToPdf` in renderers up to 0.11.9 was image-only: each slide was rasterized and embedded as a PNG on a PDF page. From opf-render 0.12.0 (opf-render#90, [roadmap](plans/pdf-export.md)) the default `mode: \"vector\"` writes PDF text objects in embedded TrueType subsets of the fonts you supply or the bundled open pack (the same files the PNG preview uses; system fonts are rejected, a face whose OS/2 `fsType` forbids embedding is never embedded, the report names requested and resolved faces), with `ToUnicode` maps and `/ActualText` where the glyph map cannot give the text, vector shapes, gradients and images, and no second layout pass. `mode: \"raster\"` keeps the image-per-slide output as an explicit compatibility mode. Extraction was checked in pdf.js, PDFium and poppler on Latin, CJK, right-to-left and Indic samples and all 805 example slides; PDFium misreads some Thai and Burmese marks, as it does in Chrome's own PDFs. No PDF/UA or PDF/A claim.\n\n## Office compatibility pack\n\n`loadFonts({ pack: 'office' })` from `@openpresentation/opf-render/fonts-node` supplies regular, bold, italic, and bold italic faces of Carlito, Caladea, Arimo, Tinos, Cousine, and Gelasio, plus the base Roboto pack. Package versions are pinned and each face carries its distribution's license notice. `includeBaseFonts: false` omits Roboto. Loading never installs fonts into the operating system or downloads fonts at render time.\n\n```js\nconst fonts = await loadFonts({\n pack: 'office',\n substitutionPolicy: 'metric', // Default for the office pack; no visual fallback.\n});\nfonts.registry.resolveFont({fontFamily: 'Calibri', fontWeight: 400});\n// requestedFamily: Calibri, resolvedFamily: Carlito, compatibility: metric\n```\n\n`loadFonts` defaults to `substitutionPolicy: 'none'` (the office pack to `'metric'`). Policies are `none`, `metric`, and `visual`; visual permits both curated tiers. An explicit `fallbackFamily` is a separate, reported `generic` fallback. Aliases are explicit visual substitutions and never establish metric compatibility. `resolveFont` reports exact resolutions as well; `substitutions` only collects changes. Resolution records include requested/resolved weights, italic, source path, and supporting upstream information where available.\n\n| Requested family | Bundled substitute | Current automatic tier |\n| --- | --- | --- |\n| Calibri | Carlito | Metric intent, standard 400/700 styles |\n| Cambria | Caladea | Visual: advances differ from Cambria 6.99 by a mean of 2.7% (FF-31 measurement). Metric-mode registries still use it, reported as visual (`metricModeFallback`) |\n| Arial | Arimo | Metric, standard 400/700 styles |\n| Times New Roman | Tinos | Metric, standard 400/700 styles |\n| Courier New | Cousine | Metric, standard 400/700 styles |\n| Georgia | Gelasio | Metric with `liga` and `clig` off (`disabledFeatures`, applied by opf-render): advances identical on all 300 corpus strings in four styles. With default features, ligature runs differ by up to 1.02% |\n| Calibri Light | Carlito | Visual: the bundle has no Carlito Light face |\n| Aptos, Aptos Display, Aptos Narrow, Aptos Serif | Intos, Intos Display, Intos Narrow, Intos Serif (office pack) | Metric: 0.000% mean and maximum against Aptos 2.01, all four styles; Roboto and Carlito are visual alternates |\n\nUpstream evidence: [Carlito](https://github.com/googlefonts/carlito), [Fontconfig mappings](https://chromium.googlesource.com/external/fontconfig/+/refs/heads/main/conf.d/30-metric-aliases.conf), [Arimo](https://github.com/google/fonts/blob/main/ofl/arimo/DESCRIPTION.en_us.html), [Tinos](https://github.com/google/fonts/blob/main/ofl/tinos/DESCRIPTION.en_us.html), [Cousine](https://github.com/google/fonts/blob/main/ofl/cousine/DESCRIPTION.en_us.html), and [Gelasio](https://github.com/SorkinType/Gelasio). Metric classification describes compatibility intent within the stated style scope, not universal identical output. Missing matching weights cannot silently qualify for the metric tier.\n\nRR-17: Liberation Sans, Serif and Mono are not bundled (Reserved Font Name, about 4.4 MB); a document that names them previews with Arimo, Tinos and Cousine, the Croscore faces Liberation 2 is built from (metric, 0.0000% in four styles). Each Latin replacement has a per-family qualification (`scripts/qualify-latin-fonts.mjs`) and a fixture in every host; see the [font tracker](programs/font-fidelity-everywhere/font-tracker.md).\n\nThe exported `FONT_COMPATIBILITY` list also contains optional visual candidates and CJK families. Listing a candidate does not bundle it or imply complete character coverage. Liberation Sans Narrow is a separate legacy distribution with a different license history; it is not part of this bundle. Wingdings, Webdings, and Symbol require character mapping before substitution; an ordinary fallback fails with `font-encoding-required`. Cambria Math previews with STIX Two Math and Segoe UI Emoji with Noto Color Emoji once opf-render's optional math and emoji packs are loaded (FF-45, [special families: emoji and math](programs/font-fidelity-everywhere/special-families-emoji-math.md)); without the pack a renderer reports `font-unavailable` naming it, like any other routed family (the former `math-font-required` failure is gone). OPF has no equation model: a Cambria Math run is text drawn per character in a math face, not MATH-table layout.\n\nDrawingML tokens such as `+mn-lt` resolve through the registry's explicit `themeFonts` option before substitution. Supply concrete `majorLatin`, `minorLatin`, and, where used, `majorEastAsia`, `minorEastAsia`, `majorComplexScript`, or `minorComplexScript` families. Missing theme mappings fail. This helper does not yet extract theme font records or embedded fonts from imported PPTX files.\n\n### Measured results and experimental fonts\n\n`node --import ./scripts/register-local-opf.mjs scripts/test-office-fonts.mjs --system` compares the bundle to reference fonts already installed in macOS's Supplemental directory. It does not redistribute reference fonts. The report records source-file hashes and individual shaped widths. Across four samples and four styles, Arimo/Arial, Tinos/Times New Roman, and Cousine/Courier New matched exactly on 48 runs. Gelasio/Georgia differed on ligature-containing runs, with a maximum difference of 2.0125%. Individual basic-Latin advances matched; disabling optional ligatures removed the tested difference. Until feature handling is consistent across outputs, the policy conservatively labels Gelasio approximate. Calibri and Cambria reference fonts were not available for this comparison.\n\nAkasia, assessed earlier ([v0.0.2 open-file assessment](evidence/akasia-assessment/README.md)), is dropped: its repository is no longer available, and Intos replaces it. `EXPERIMENTAL_FONT_CANDIDATES` now records Microsoft's Selawik, measured for Segoe UI on 2026-09-29 and rejected: 0.16% mean and 2.5% maximum in regular, no italic faces, 349 code points, lowercase 4.8% shorter. The acceptance rules for replacement fonts are in the [licensing table](programs/font-fidelity-everywhere/font-licensing.md#replacement-font-acceptance-rules).\n\nAn original OPF font project is technically feasible: independently designed or suitably open-licensed glyph outlines can be fitted to target advance widths, placement, vertical metrics, and shaping behavior. A successful font needs a reproducible source build, provenance, style/coverage tests, visual review, and cross-renderer conformance. Matching bounding boxes alone is insufficient: [OpenType horizontal metrics](https://learn.microsoft.com/en-us/typography/opentype/spec/hmtx) and [glyph positioning](https://learn.microsoft.com/en-us/typography/opentype/spec/gpos) jointly control text placement. Universal pixel identity across rasterizers is not the acceptance criterion; measured layout preservation over an explicit test matrix is.\n\n## Verification and remaining work\n\nScript fonts (CJK, Arabic, Hebrew, Indic, Thai, Khmer, Myanmar and the rest) have their own shaping corpora and per-family qualification (FF-44, RR-17): see [script-corpora.md](programs/font-fidelity-everywhere/script-corpora.md). It records, per script, glyph coverage, fontkit against HarfBuzz and Chromium, the installed originals measured in place, and the known limits (fontkit has no Myanmar shaper; the PNG path of resvg-js mis-shapes the Indic scripts, Thai, Lao, Khmer and Myanmar).\n\n`pnpm test:fonts` checks that editor and SVG geometry match and that every native PPTX text box has the same coordinates and measured line breaks. With opf-pptx FF-31 (opf-pptx#63), export names the chosen family, not the preview substitute. It writes artifacts to `artifacts/fonts/`. A real-browser check of the same Roboto run measured 324.032 pixels versus the font engine's 324.170 pixels at 25 pixels, a difference of 0.138 pixels. These are measured tolerances, not a promise of pixel identity.\n\nPPTX records the chosen font family (FF-31); it never records a preview replacement and never embeds a proprietary font binary. PowerPoint still needs those fonts installed, through Office, the OS or Microsoft 365 cloud fonts, or it substitutes them. Line height remains the shared 1.22 multiplier, rather than a complete ascent/descent model. Rich-text font overrides, mixed-script fallback and bidi layout, specialized payload internals, and native font embedding remain active fidelity work. Passing a width provider does not remove those limits.\n"
110
110
  },
111
111
  {
112
112
  "slug": "footnotes-citations-captions",
@@ -118,7 +118,7 @@ var docsData = Object.freeze([
118
118
  "slug": "format-card",
119
119
  "file": "docs/format-card.md",
120
120
  "title": "OPF format card",
121
- "markdown": '# OPF format card\n\nA self-contained authoring reference for agents and humans writing `*.opf.json` documents, sized for pasting into a model\'s context. The canonical contract is the JSON Schema (`https://openpresentation.org/schema/opf/v1`, in [`spec/schemas/opf.schema.json`](../spec/schemas/opf.schema.json)); this card compresses it. Validate with `validate` from `@openpresentation/opf` or `opf validate <file>` ([validate](validate.md)).\n\n## Document shape\n\nA presentation is one JSON object; only `slides` is required.\n\n```json\n{ "name": "Minimal Deck", "slides": [{ "title": "Minimal Deck" }] }\n```\n\nOptional top-level fields, grouped:\n\n- Identity: `name`, `description`, `filename`, `author`, `organization`, `speaker`, `tags`.\n- Intent (used by AI generation): `audience`, `purpose`, `tone`, `language`, `narrative`, `takeaway`, `duration`.\n- Appearance: `design` (`theme`, `colorScheme`, `fontScheme`, `dimensions`, `background`, `logo`, `watermark`, `header`, `footer`, alignment hints), `variables`.\n- Resources: `assets` (registry referenced as `asset:<id>`), `catalogs` (inline records and/or custom sources per kind).\n- Machine state: `extensions` (preserved, never rendered).\n\n## Slides and content\n\nA slide carries `title` / `subtitle` / `tag` / `notes` / `section` / `beat` / `layout` / `id` / `extensions` plus content in one of three shapes \u2014 pick the loosest that says what you mean:\n\n1. **Root payload** \u2014 content-kind fields directly on the slide. The kind is inferred from the field: `text`, `items` (list), `bullets`, `image`, `video`, `chart`, `table`, `code`, `metric`, `quote`, `timeline`. Several kinds at the root are shorthand for `blocks`.\n2. **`blocks`** \u2014 an ordered array of payloads when placement should stay engine-inferred. A block is a leaf payload or a **group**: `{ "type": "group", "blocks": [...], "composition": { ... } }`. Groups nest (hard cap 32; stay \u2264 3 in practice) and cannot mix `blocks` with leaf fields.\n3. **Promoted regions** \u2014 a 3\xD73 grid when position matters. Rows `top|middle|bottom`, columns `left|center|right`, spans with `+`, intersections with `:` \u2014 `"left"`, `"center+right"`, `"top:left"`, `"middle+bottom:center+right"`. Keys must not overlap; regions cannot mix with a root payload.\n\n`composition` (on a slide or group) arranges children: `{ "mode": "auto|grid|row|column", "columns": 1-12, "weights": [..], "gap": 0-0.1, "padding": 0-0.2, "minFontSize": 8-32, "overflow": "warn|error" }`. Weights are relative track sizes; gap/padding are fractions of the canvas short edge. Engines own placement \u2014 there is no x/y.\n\nPayload notes: chart is `{ "type": "<chart-type id>", "data": { "columns": [...], "rows": [...] } }` or `{ "data": { "dataset": "<id>" } }`; table rows may hold scalars, `TextRun[]`, or styled cells `{ "value", "style": { "fill", "color", "align", "borders", ... }, "colSpan", "rowSpan" }`; `metric`/`quote`/`code` accept string shorthand; list nesting uses `level` on items, not nested payloads.\n\n## Rich text and color references\n\n`text`, list items, bullets, and table cells accept `TextRun[]`: strings or `{ "text", "bold", "italic", "underline", "strikethrough", "color", "fontSize", "fontFamily", "link", "superscript", "subscript" }`.\n\nEvery content color field (`TextRun.color`, table cell `style.fill` / `style.color`, cell border `color`) accepts three forms:\n\n- Literal hex: `"#0F172A"`, `"#B42318CC"`.\n- A color-scheme name, resolved through the effective scheme: slots `accent1`\u2013`accent6`, `dark1`, `dark2`, `light1`, `light2`, `hyperlink`, `followedHyperlink`, or roles `primary`, `secondary`, `accent`, `background`, `surface`, `text`, `textSecondary`.\n- A variable reference `var:<id>` into the top-level map: `"variables": { "risk": "#B42318" }` (or `{ "type": "color", "value": "#B42318", "description": "..." }`).\n\nPrefer names and variables over hex \u2014 they survive re-theming. Unknown `var:` ids warn, never error. The styled cell and border color fields enforce the three forms at the schema level; run colors tolerate any string (unrecognized values warn and renderers fall back to the theme), so imported decks keep validating.\n\n## Ids and extensions\n\n`id` is optional on slides and on any content payload (including groups), unique document-wide. Use ids when something outside the document must address content across edits \u2014 patch edits, comments, review state. `extensions` objects at document, slide, and payload scope carry machine state; engines ignore and preserve them.\n\n## Catalog references\n\nReusable vocabulary lives in catalogs; references are kebab-case ids: `narrative`, `tone`, `purpose`, `audience`, `language`, `design.theme`, `design.colorScheme`, `design.fontScheme`, `Slide.layout`, `Chart.type`, socials keys. Resolution: inline `catalogs.<kind>.records[]` \u2192 `catalogs.<kind>.source` \u2192 bundled default catalog (browsable at https://pptx.gallery). Object form overrides a record per key: `{ "id": "cool-horizon", "accent1": "#0F4C81" }`. Unknown ids warn, never error. `opf bundle <in> <out>` inlines every record a document uses so it needs no catalog lookups beyond itself (remote media and data assets are separate).\n\n## Design in three lines\n\n`design.theme` bundles scheme + fonts + background + dimensions; `design.colorScheme` / `fontScheme` override it; `slides[].design` overrides per slide; resolution is per field, most specific wins. Backgrounds accept slot names (`"light1"`) or hex. `false` suppresses inherited `watermark` / `header` / `footer`.\n\n## Prefer / never\n\nPrefer: string shorthands; bare catalog ids; inference over explicit `type`; regions only when position matters; groups only when `blocks` ordering is not enough; names/`var:` over hex in color fields; `items` for lists (`bullets` only for plain text-style bullets).\n\nNever: loose chart or table fields directly on a slide; region keys mixed with a root payload; overlapping region keys; `blocks` mixed with leaf fields on one payload; duplicate ids; x/y or pixel geometry (it does not exist in OPF).\n\n## Two canonical slides\n\n```json\n{\n "id": "headline",\n "title": "Adoption Doubled",\n "left": { "id": "kpi", "metric": { "value": "2.1x", "label": "Adoption", "trend": "up" } },\n "center+right": { "chart": { "type": "line", "data": { "columns": ["Month", "Teams"], "rows": [["Jan", 12], ["Feb", 18]] } } }\n}\n```\n\n```json\n{\n "id": "risks",\n "title": "What Could Go Wrong",\n "composition": { "mode": "column" },\n "blocks": [\n { "items": [["Two regions at ", { "text": "85% utilization", "color": "var:risk", "bold": true }]] },\n { "quote": { "text": "Exceptions became visible before they became escalations.", "attribution": "VP Operations" } }\n ],\n "extensions": { "authoring": { "locked": true } }\n}\n```\n'
121
+ "markdown": '# OPF format card\n\nA self-contained authoring reference for agents and humans writing `*.opf.json` documents, sized for pasting into a model\'s context. The canonical contract is the JSON Schema (`https://openpresentation.org/schema/opf/v1`, in [`spec/schemas/opf.schema.json`](../spec/schemas/opf.schema.json)); this card compresses it. Validate with `validate` from `@openpresentation/opf` or `opf validate <file>` ([validate](validate.md)).\n\n## Document shape\n\nA presentation is one JSON object; only `slides` is required.\n\n```json\n{ "name": "Minimal Deck", "slides": [{ "title": "Minimal Deck" }] }\n```\n\nOptional top-level fields, grouped:\n\n- Identity: `name`, `description`, `filename`, `author`, `organization`, `speaker`, `tags`.\n- Intent (used by AI generation): `audience`, `purpose`, `tone`, `language`, `narrative`, `takeaway`, `duration`.\n- Appearance: `design` (`theme`, `colorScheme`, `fontScheme`, `dimensions`, `background`, `logo`, `watermark`, `header`, `footer`, alignment hints), `variables`.\n- Resources: `assets` (registry referenced as `asset:<id>`), `catalogs` (the records the document embeds, grouped by catalog: `default`, `custom` and named groups with a `source`).\n- Machine state: `extensions` (preserved, never rendered).\n\n## Slides and content\n\nA slide carries `title` / `subtitle` / `tag` / `notes` / `section` / `beat` / `layout` / `id` / `extensions` plus content in one of three shapes \u2014 pick the loosest that says what you mean:\n\n1. **Root payload** \u2014 content-kind fields directly on the slide. The kind is inferred from the field: `text`, `items` (list), `bullets`, `image`, `video`, `chart`, `table`, `code`, `metric`, `quote`, `timeline`. Several kinds at the root are shorthand for `blocks`.\n2. **`blocks`** \u2014 an ordered array of payloads when placement should stay engine-inferred. A block is a leaf payload or a **group**: `{ "type": "group", "blocks": [...], "composition": { ... } }`. Groups nest (hard cap 32; stay \u2264 3 in practice) and cannot mix `blocks` with leaf fields.\n3. **Promoted regions** \u2014 a 3\xD73 grid when position matters. Rows `top|middle|bottom`, columns `left|center|right`, spans with `+`, intersections with `:` \u2014 `"left"`, `"center+right"`, `"top:left"`, `"middle+bottom:center+right"`. Keys must not overlap; regions cannot mix with a root payload.\n\n`composition` (on a slide or group) arranges children: `{ "mode": "auto|grid|row|column", "columns": 1-12, "weights": [..], "gap": 0-0.1, "padding": 0-0.2, "minFontSize": 8-32, "overflow": "warn|error" }`. Weights are relative track sizes; gap/padding are fractions of the canvas short edge. Engines own placement \u2014 there is no x/y.\n\nPayload notes: chart is `{ "type": "<chart-type id>", "data": { "columns": [...], "rows": [...] } }` or `{ "data": { "dataset": "<id>" } }`; table rows may hold scalars, `TextRun[]`, or styled cells `{ "value", "style": { "fill", "color", "align", "borders", ... }, "colSpan", "rowSpan" }`; `metric`/`quote`/`code` accept string shorthand; list nesting uses `level` on items, not nested payloads.\n\n## Rich text and color references\n\n`text`, list items, bullets, and table cells accept `TextRun[]`: strings or `{ "text", "bold", "italic", "underline", "strikethrough", "color", "fontSize", "fontFamily", "link", "superscript", "subscript" }`.\n\nEvery content color field (`TextRun.color`, table cell `style.fill` / `style.color`, cell border `color`) accepts three forms:\n\n- Literal hex: `"#0F172A"`, `"#B42318CC"`.\n- A color-scheme name, resolved through the effective scheme: slots `accent1`\u2013`accent6`, `dark1`, `dark2`, `light1`, `light2`, `hyperlink`, `followedHyperlink`, or roles `primary`, `secondary`, `accent`, `background`, `surface`, `text`, `textSecondary`.\n- A variable reference `var:<id>` into the top-level map: `"variables": { "risk": "#B42318" }` (or `{ "type": "color", "value": "#B42318", "description": "..." }`).\n\nPrefer names and variables over hex \u2014 they survive re-theming. Unknown `var:` ids warn, never error. The styled cell and border color fields enforce the three forms at the schema level; run colors tolerate any string (unrecognized values warn and renderers fall back to the theme), so imported decks keep validating.\n\n## Ids and extensions\n\n`id` is optional on slides and on any content payload (including groups), unique document-wide. Use ids when something outside the document must address content across edits \u2014 patch edits, comments, review state. `extensions` objects at document, slide, and payload scope carry machine state; engines ignore and preserve them.\n\n## Catalog references\n\nReusable records live in catalogs; a reference is a bare id or `name:id`: `narrative`, `tone`, `purpose`, `audience`, `design.theme`, `design.colorScheme`, `design.fontScheme`, `Slide.layout`. `catalogs` holds the records a document embeds, grouped by catalog: `default` (with its `source`), `custom` (the document\'s own) and named groups (`"acme": { "source": "pkg:@acme/opf-catalog", ... }`, referenced as `acme:hero`). Resolution: `custom` \u2192 embedded `default` \u2192 the catalog the host registered for its source; nothing is fetched. Object form overrides a record per key: `{ "id": "cool-horizon", "accent1": "#0F4C81" }`. A reference that resolves nowhere warns (`opf/unresolved-reference`), never errors. `opf embed <in> <out>` embeds every record a document uses so it renders with no catalog registered. `language` (a BCP-47 tag), `Chart.type` and the socials keys are engine vocabularies, not references.\n\n## Design in three lines\n\n`design.theme` bundles scheme + fonts + background + dimensions; `design.colorScheme` / `fontScheme` override it; `slides[].design` overrides per slide; resolution is per field, most specific wins. Backgrounds accept slot names (`"light1"`) or hex. `false` suppresses inherited `watermark` / `header` / `footer`.\n\n## Prefer / never\n\nPrefer: string shorthands; bare ids for default-catalog records; inference over explicit `type`; regions only when position matters; groups only when `blocks` ordering is not enough; names/`var:` over hex in color fields; `items` for lists (`bullets` only for plain text-style bullets).\n\nNever: loose chart or table fields directly on a slide; region keys mixed with a root payload; overlapping region keys; `blocks` mixed with leaf fields on one payload; duplicate ids; x/y or pixel geometry (it does not exist in OPF).\n\n## Two canonical slides\n\n```json\n{\n "id": "headline",\n "title": "Adoption Doubled",\n "left": { "id": "kpi", "metric": { "value": "2.1x", "label": "Adoption", "trend": "up" } },\n "center+right": { "chart": { "type": "line", "data": { "columns": ["Month", "Teams"], "rows": [["Jan", 12], ["Feb", 18]] } } }\n}\n```\n\n```json\n{\n "id": "risks",\n "title": "What Could Go Wrong",\n "composition": { "mode": "column" },\n "blocks": [\n { "items": [["Two regions at ", { "text": "85% utilization", "color": "var:risk", "bold": true }]] },\n { "quote": { "text": "Exceptions became visible before they became escalations.", "attribution": "VP Operations" } }\n ],\n "extensions": { "authoring": { "locked": true } }\n}\n```\n'
122
122
  },
123
123
  {
124
124
  "slug": "handoff-2026-09-08",
@@ -190,25 +190,25 @@ var docsData = Object.freeze([
190
190
  "slug": "how-opf-works",
191
191
  "file": "docs/how-opf-works.md",
192
192
  "title": "How OPF Works",
193
- "markdown": '# How OPF Works\n\nAn OPF document is one JSON file that answers three questions about a presentation:\n\n- **What does it say?** \u2014 `slides`, with content payloads and assets.\n- **Who is it for and why?** \u2014 `audience`, `purpose`, `tone`, `language`, and `narrative`.\n- **What should it look like?** \u2014 `design`, resolved through themes, color schemes, and font schemes.\n\nThe document records intent; an engine (a renderer, exporter, or editor) turns that intent into pixels or `.pptx` output. OPF deliberately stops at the format boundary: it never embeds OOXML, layout geometry, or renderer-specific state. You \u2014 or your agent \u2014 own the story, the data, and the ask; the format\'s job is to keep all of that readable, diffable, and out of `<p:sp>` tags.\n\n## Anatomy of a document\n\n```\nPresentation\n\u251C\u2500\u2500 identity ...... name, description, organization, speaker, author\n\u251C\u2500\u2500 intent ........ audience, purpose, tone, language, narrative, takeaway, duration\n\u251C\u2500\u2500 content ....... slides[]\n\u2502 \u251C\u2500\u2500 title / subtitle / tag / notes / section / beat / layout\n\u2502 \u2514\u2500\u2500 one content shape:\n\u2502 root payload (a single content kind)\n\u2502 blocks[] (ordered payloads, placement inferred)\n\u2502 region keys (3x3 placement grid)\n\u251C\u2500\u2500 design ........ theme, colorScheme, fontScheme, background, logo, header, footer\n\u251C\u2500\u2500 variables ..... named colors, referenced from content as "var:<id>"\n\u251C\u2500\u2500 assets ........ named media sources, referenced as "asset:<id>"\n\u2514\u2500\u2500 catalogs ...... per-kind overrides: inline records and/or custom sources\n```\n\nOnly `slides` is required. The smallest valid document:\n\n```json\n{\n "name": "Minimal OPF Deck",\n "slides": [\n { "title": "Minimal OPF Deck" },\n { "title": "Next Steps", "text": "Use this as a starting point." }\n ]\n}\n```\n\nEverything else in the format is optional and additive.\n\n## Slides and content\n\nA slide carries its content in one of three shapes. Pick the loosest shape that says what you mean \u2014 engines handle placement.\n\n**1. Root payload** \u2014 one content kind directly on the slide. The kind is inferred from the field present (`text`, `items`, `chart`, `table`, `image`, `video`, `code`, `metric`, `quote`, `timeline`); see [`content-payloads.md`](./content-payloads.md) for the full table.\n\n```json\n{\n "title": "Operating Metric",\n "metric": { "value": "42%", "label": "Review cycle reduction", "trend": "up" }\n}\n```\n\nMultiple kinds at the slide root (with no explicit `type`, `blocks`, or regions) are shorthand for the equivalent `blocks`:\n\n```json\n{\n "title": "Habitat",\n "text": "Jaguars are strongly associated with water and dense cover.",\n "items": ["Rainforests and flooded wetlands", "Large defended territories"]\n}\n```\n\n**2. `blocks`** \u2014 an ordered list of payloads when a slide has several pieces of content but placement should stay renderer-inferred:\n\n```json\n{\n "title": "Customer Feedback",\n "blocks": [\n { "table": { "columns": ["Theme", "Mentions"], "rows": [["Speed", 42], ["Ease of use", 31]] } },\n { "quote": { "text": "The new workflow cut review time in half.", "attribution": "Operations Lead" } }\n ]\n}\n```\n\n**3. Promoted region keys** \u2014 a 3\xD73 placement grid when position matters:\n\n```\n left center right\n +--------------------+--------------------+--------------------+\n top | top:left | top:center | top:right |\n +--------------------+--------------------+--------------------+\n middle | middle:left | middle:center | middle:right |\n +--------------------+--------------------+--------------------+\n bottom | bottom:left | bottom:center | bottom:right |\n +--------------------+--------------------+--------------------+\n\n A bare column key ("left") spans all three rows.\n A bare row key ("top") spans all three columns.\n Keys span neighbors with "+" and intersect rows with columns via ":".\n```\n\nThe spans compose into the slide shapes you actually want:\n\n```\n "left" + "center+right" "top" + "middle+bottom"\n (sidebar + main) (headline band + body)\n +----------+------------------+ +-------------------------------+\n | | | | top |\n | | | +-------------------------------+\n | left | center+right | | |\n | | | | middle+bottom |\n | | | | |\n +----------+------------------+ +-------------------------------+\n\n "top" + "middle+bottom:left" + "middle+bottom:center+right"\n (headline band, then sidebar + main)\n +---------------------------------------------+\n | top |\n +---------------+-----------------------------+\n | | |\n | middle+bottom | middle+bottom:center+right |\n | :left | |\n | | |\n +---------------+-----------------------------+\n```\n\nThat last shape in JSON:\n\n```json\n{\n "title": "Adoption Doubled",\n "top": { "text": "Adoption doubled while support load stayed flat." },\n "middle+bottom:left": { "metric": { "value": "2.1x", "label": "Adoption" } },\n "middle+bottom:center+right": {\n "chart": { "type": "line", "data": { "columns": ["Month", "Teams"], "rows": [["Jan", 12], ["Feb", 18]] } }\n }\n}\n```\n\nAnd the two-column shape from the grid above:\n\n```json\n{\n "title": "Operating Snapshot",\n "left": { "table": { "columns": ["Metric", "Value"], "rows": [["Revenue", "$4.2M"]] } },\n "center+right": { "chart": { "type": "line", "data": { "columns": ["Month", "Revenue"], "rows": [["Jan", 3.4]] } } }\n}\n```\n\nRegion keys on one slide must not overlap, and regions cannot be mixed with a root payload. Slide-level strings `title`, `subtitle`, and `tag` sit alongside whichever content shape you use, and render into the matching placeholders of the resolved layout.\n\n## Layouts are hints, not contracts\n\n`Slide.layout` optionally references a record in the `layouts` catalog. The layout\'s placeholders describe what the layout *exposes* (a title slot, chart regions, image treatment) \u2014 they do not constrain what the slide may contain. This loose coupling is intentional:\n\n- A slide may use any region keys or payloads regardless of its declared layout. Validators do not error on a slide/layout mismatch.\n- When `layout` is omitted, engines infer one from the slide\'s payload or region keys.\n- Free-form layout names that don\'t resolve through any catalog fall through to engine-defined layouts.\n\nThe principle, used throughout OPF: **slides are the source of truth**. Layouts, narratives, and design records guide rendering; they never invalidate content.\n\n## Narrative is a plan; the deck holds a pointer\n\nA narrative is a **plan**: a story arc, its ordered **beats** (labeled segments such as `hook`, `problem`, `evidence`, `ask`) and what each beat must do. A deck is the **product**. So the plan lives in the `narratives` catalog and the deck holds only a pointer: `narrative` is a string, a catalog id (`"classic-story"`, `"pitch-deck"`), an HTTPS URL or a `pkg:` reference. A beat carries its blueprint: `type` is the slide\'s `Slide.type`, `layout` is its `Slide.layout`, and `instructions` and `thoughtCues` guide the author. A record\'s `duration { min, max }` is the talk length in minutes it suits.\n\nSlides link themselves to beats with `Slide.beat`. Nothing forces them to, and the preview and the PPTX export draw nothing from the narrative. `validate` warns about a `slides[].beat` id the narrative does not define (`opf/unknown-beat`) and about a root `duration` outside the narrative\'s range (`opf/duration-outside-narrative`), and reports a beat that no slide references (`opf/unused-beat`, info).\n\nA custom narrative is a record in `catalogs.narratives.records`, and `narrative` names its id. There is one shape, the catalog record:\n\n```json\n{\n "name": "Schema Pitch",\n "narrative": "technical-proof",\n "duration": 12,\n "catalogs": {\n "narratives": {\n "records": [\n {\n "id": "technical-proof",\n "name": "Technical Proof",\n "duration": { "min": 8, "max": 20 },\n "beats": [\n { "id": "contract", "name": "Contract", "type": "text", "instructions": "State what stays stable." },\n { "id": "evidence", "name": "Evidence", "type": "chart" },\n { "id": "adoption", "name": "Adoption", "type": "list" }\n ]\n }\n ]\n }\n },\n "slides": [\n { "beat": "contract", "title": "The Contract", "text": "Beats describe intent without constraining slides." },\n { "beat": ["evidence", "adoption"], "title": "Proof And Ask", "items": ["One slide may cover several beats."] }\n ]\n}\n```\n\nThe plan is also a skeleton deck: a planner emits slides that carry `beat`, `type`, `layout`, `title` and `notes`, which is a valid deck from the start. Deck-level concerns that are not part of the storyline (`audience`, `tone`, `takeaway`, `duration`) live as siblings on the presentation root.\n\n## Catalog references and how they resolve\n\nMost reusable values in OPF are references into **catalogs**: named collections of records, each identified by a kebab-case `id`. The referencing fields are `narrative`, `language`, `tone`, `audience`, `purpose`, `design.theme`, `design.colorScheme`, `design.fontScheme`, `Slide.layout`, `Chart.type`, and the platform keys in `socials`.\n\nEvery reference resolves through the same chain, first match wins:\n\n```\n "design": { "colorScheme": "cool-horizon" }\n |\n v\n 1. catalogs.colorSchemes.records[] inline records in this document\n | miss\n v\n 2. catalogs.colorSchemes.source custom registry declared in this document\n | miss\n v\n 3. default catalog https://www.pptx.gallery/color-schemes\n | miss (engines read the pinned snapshot in\n v spec/catalogs/ and never fetch it)\n validation warning \u2014 never an error \u2014 and an engine fallback\n```\n\n`catalogs.<kind>.source` is one source or an ordered array of sources. An array is a search path: the engine consults the sources in order, the first record with the id wins, and the default catalog is appended implicitly at the end (so `[a, b]` behaves as `[a, b, default]`). Inline `records[]` still win over every source. Engines never fetch a source at run time: a source is resolved only from records the host supplies for it (for example the `catalogSources` option of `opf-render` and `opf-pptx`), or from the bundled snapshot when it is a `pptx.gallery` or `pkg:@openpresentation/opf/...` source; an unknown source contributes nothing and the lookup continues with the next entry. The validator treats any non-empty array like a string source: ids it cannot check against the bundled catalogs are not reported as unknown.\n\n> **Decision, 2026-09-30 (agent decision, vetoable).** The spec coverage audit found that `opf-render` threw `source.startsWith is not a function` for an array `source`, although the schema allows it. The array form is implemented as the search path the schema describes, with the resolution rules above, instead of being removed from the schema. The owner can veto this by narrowing `CatalogEntry.source` to a single string (a breaking change for documents that use the array form).\n\npptx.gallery publishes the default catalog; the copy bundled in `spec/catalogs/` and the `@openpresentation/opf` package is a pinned snapshot of it, so resolution is deterministic offline. See [the default catalog](default-catalog.md) for the endpoints and the snapshot.\n\nWhen a reference is omitted entirely, engines fall back to their own defaults (see [`spec/reference/engine-defaults.json`](../spec/reference/engine-defaults.json) for a reference example \u2014 that file is engine configuration, not part of the document contract).\n\nThree reference forms are accepted wherever a catalog reference is allowed:\n\n- **Bare id** for the common case: `"narrative": "classic-story"`.\n- **Object form** for catalog-backed overrides: `{ "id": "cool-horizon", "accent1": "#0F4C81" }` resolves the record as a base, then inline fields win per key.\n- **URL or `pkg:` reference**, which skips the catalog lookup and resolves directly.\n\nA document can carry its own records or point at a private registry, which also silences unknown-id warnings for that kind:\n\n```json\n{\n "name": "Branded Deck",\n "design": { "colorScheme": "acme-brand" },\n "catalogs": {\n "colorSchemes": {\n "records": [{ "id": "acme-brand", "name": "Acme Brand", "accent1": "#0F4C81", "light1": "#FFFFFF", "dark1": "#0B1B2B" }]\n },\n "narratives": { "source": "https://catalogs.example.com/narratives" }\n },\n "slides": [{ "title": "Branded Deck" }]\n}\n```\n\n## Design in one paragraph\n\n`design` selects a `theme` (which bundles default color scheme, font scheme, background, and dimensions) and may override any of those directly; `Slide.design` overrides the deck design per slide. More specific always wins, field by field. Color schemes and font schemes each support two mixable models \u2014 OOXML slots/pairs that round-trip to PowerPoint, and abstract roles (`primary`, `heading`, `code`, \u2026) that engines map onto slots. Content color fields (rich-text runs, styled table cells) reference the design system by name \u2014 a scheme slot (`accent2`), a role (`text`), or a `var:<id>` entry from the top-level `variables` map \u2014 so styled content follows a re-theme instead of freezing hex values. The full precedence chain with worked examples is in [`design-resolution.md`](./design-resolution.md).\n\n## Assets\n\nBinary content lives in the top-level `assets` registry, keyed by id. Content payloads and design fields reference entries with `asset:<id>` strings; asset `src` values accept HTTPS URLs, data URIs, and paths resolved against the OPF file location.\n\n## A complete small deck\n\nEverything above, together \u2014 intent metadata, a catalog-backed narrative with beats, design, an organization and speaker, an asset-backed chart, regions, notes, and sections:\n\n```json\n{\n "$schema": "https://openpresentation.org/schema/opf/v1",\n "name": "Q3 Business Review",\n "description": "Quarterly review for the executive team.",\n "audience": "executive",\n "purpose": "decide",\n "tone": "formal",\n "language": "en-US",\n "narrative": "qbr",\n "takeaway": "Approve the expanded rollout budget.",\n "duration": 20,\n "organization": {\n "id": "acme",\n "name": "Acme Corp",\n "domain": "acme.com",\n "socials": { "linkedin": "acme" }\n },\n "speaker": { "id": "alice", "name": "Alice Chen", "title": "VP Operations", "organizationId": "acme" },\n "design": {\n "theme": "classic",\n "colorScheme": "forest-green",\n "footer": { "left": { "organization": true }, "right": { "slideNumber": true } }\n },\n "assets": {\n "adoption-csv": { "src": "./data/adoption.csv", "alt": "Monthly adoption data" }\n },\n "slides": [\n {\n "layout": "title",\n "beat": "objectives",\n "title": "Q3 Business Review",\n "subtitle": "Operations \u2014 October 2025"\n },\n {\n "beat": "performance-headline",\n "title": "Adoption Doubled",\n "left": { "metric": { "value": "2.1x", "label": "Quarter-over-quarter adoption", "trend": "up" } },\n "center+right": {\n "chart": { "type": "line", "data": { "columns": ["Month", "Active Teams"], "rows": [["Jul", 8], ["Aug", 12], ["Sep", 17]], "source": { "src": "asset:adoption-csv" } } }\n },\n "notes": "Pause here; this is the slide the decision hangs on."\n },\n {\n "beat": "risks",\n "section": "Decision",\n "title": "What Could Go Wrong",\n "items": [\n "Capacity: two regions are at 85% utilization.",\n {\n "text": "Churn risk in the legacy tier.",\n "description": "Mitigation: migration incentives ship in November."\n }\n ]\n },\n {\n "beat": "asks",\n "title": "The Ask",\n "text": "Approve $1.2M to expand the rollout to all regions in Q4."\n }\n ]\n}\n```\n\nThe beat ids (`objectives`, `performance-headline`, `risks`, `asks`) come from the `qbr` narrative record; the theme, color scheme, chart type, and layout all resolve through the bundled catalogs. For a fixture that exercises the full surface in one file, see [`examples/technical/full-feature-tour.opf.json`](../examples/technical/full-feature-tour.opf.json).\n\n## Validation philosophy\n\nTwo layers, with a deliberate split:\n\n- **Schema errors** for structural problems: wrong types, overlapping region keys, payloads mixing incompatible content kinds, a region payload missing concrete content, duplicate slide or payload ids.\n- **Warnings** for advisory drift: unknown catalog ids, unknown `var:` variable references and unrecognized run colors, narrative/slide mismatches. These never make a document invalid.\n\n`validate` from `@openpresentation/opf` applies both layers locally and, beyond them, checks references, accessibility, layout and content ([validate](validate.md)).\n\n## Where to go next\n\n- [`schema-reference.md`](./schema-reference.md) \u2014 every field of every object in the presentation schema.\n- [`catalog-schema-reference.md`](./catalog-schema-reference.md) \u2014 every field of every catalog record schema.\n- [`content-payloads.md`](./content-payloads.md) \u2014 payload shapes and inference rules with examples.\n- [`design-resolution.md`](./design-resolution.md) \u2014 the design precedence algorithm.\n- [`examples.md`](./examples.md) \u2014 guide to the example decks under `examples/`.\n\nThen write a deck, commit it, revise it, and read the diff. A two-line diff for a two-word change is the whole argument for the format.\n'
193
+ "markdown": '# How OPF Works\n\nAn OPF document is one JSON file that answers three questions about a presentation:\n\n- **What does it say?** \u2014 `slides`, with content payloads and assets.\n- **Who is it for and why?** \u2014 `audience`, `purpose`, `tone`, `language`, and `narrative`.\n- **What should it look like?** \u2014 `design`, resolved through themes, color schemes, and font schemes.\n\nThe document records intent; an engine (a renderer, exporter, or editor) turns that intent into pixels or `.pptx` output. OPF deliberately stops at the format boundary: it never embeds OOXML, layout geometry, or renderer-specific state. You \u2014 or your agent \u2014 own the story, the data, and the ask; the format\'s job is to keep all of that readable, diffable, and out of `<p:sp>` tags.\n\n## Anatomy of a document\n\n```\nPresentation\n\u251C\u2500\u2500 identity ...... name, description, organization, speaker, author\n\u251C\u2500\u2500 intent ........ audience, purpose, tone, language, narrative, takeaway, duration\n\u251C\u2500\u2500 content ....... slides[]\n\u2502 \u251C\u2500\u2500 title / subtitle / tag / notes / section / beat / layout\n\u2502 \u2514\u2500\u2500 one content shape:\n\u2502 root payload (a single content kind)\n\u2502 blocks[] (ordered payloads, placement inferred)\n\u2502 region keys (3x3 placement grid)\n\u251C\u2500\u2500 design ........ theme, colorScheme, fontScheme, background, logo, header, footer\n\u251C\u2500\u2500 variables ..... named colors, referenced from content as "var:<id>"\n\u251C\u2500\u2500 assets ........ named media sources, referenced as "asset:<id>"\n\u2514\u2500\u2500 catalogs ...... the records the document embeds, grouped by catalog: default, custom, named\n```\n\nOnly `slides` is required. The smallest valid document:\n\n```json\n{\n "name": "Minimal OPF Deck",\n "slides": [\n { "title": "Minimal OPF Deck" },\n { "title": "Next Steps", "text": "Use this as a starting point." }\n ]\n}\n```\n\nEverything else in the format is optional and additive.\n\n## Slides and content\n\nA slide carries its content in one of three shapes. Pick the loosest shape that says what you mean \u2014 engines handle placement.\n\n**1. Root payload** \u2014 one content kind directly on the slide. The kind is inferred from the field present (`text`, `items`, `chart`, `table`, `image`, `video`, `code`, `metric`, `quote`, `timeline`); see [`content-payloads.md`](./content-payloads.md) for the full table.\n\n```json\n{\n "title": "Operating Metric",\n "metric": { "value": "42%", "label": "Review cycle reduction", "trend": "up" }\n}\n```\n\nMultiple kinds at the slide root (with no explicit `type`, `blocks`, or regions) are shorthand for the equivalent `blocks`:\n\n```json\n{\n "title": "Habitat",\n "text": "Jaguars are strongly associated with water and dense cover.",\n "items": ["Rainforests and flooded wetlands", "Large defended territories"]\n}\n```\n\n**2. `blocks`** \u2014 an ordered list of payloads when a slide has several pieces of content but placement should stay renderer-inferred:\n\n```json\n{\n "title": "Customer Feedback",\n "blocks": [\n { "table": { "columns": ["Theme", "Mentions"], "rows": [["Speed", 42], ["Ease of use", 31]] } },\n { "quote": { "text": "The new workflow cut review time in half.", "attribution": "Operations Lead" } }\n ]\n}\n```\n\n**3. Promoted region keys** \u2014 a 3\xD73 placement grid when position matters:\n\n```\n left center right\n +--------------------+--------------------+--------------------+\n top | top:left | top:center | top:right |\n +--------------------+--------------------+--------------------+\n middle | middle:left | middle:center | middle:right |\n +--------------------+--------------------+--------------------+\n bottom | bottom:left | bottom:center | bottom:right |\n +--------------------+--------------------+--------------------+\n\n A bare column key ("left") spans all three rows.\n A bare row key ("top") spans all three columns.\n Keys span neighbors with "+" and intersect rows with columns via ":".\n```\n\nThe spans compose into the slide shapes you actually want:\n\n```\n "left" + "center+right" "top" + "middle+bottom"\n (sidebar + main) (headline band + body)\n +----------+------------------+ +-------------------------------+\n | | | | top |\n | | | +-------------------------------+\n | left | center+right | | |\n | | | | middle+bottom |\n | | | | |\n +----------+------------------+ +-------------------------------+\n\n "top" + "middle+bottom:left" + "middle+bottom:center+right"\n (headline band, then sidebar + main)\n +---------------------------------------------+\n | top |\n +---------------+-----------------------------+\n | | |\n | middle+bottom | middle+bottom:center+right |\n | :left | |\n | | |\n +---------------+-----------------------------+\n```\n\nThat last shape in JSON:\n\n```json\n{\n "title": "Adoption Doubled",\n "top": { "text": "Adoption doubled while support load stayed flat." },\n "middle+bottom:left": { "metric": { "value": "2.1x", "label": "Adoption" } },\n "middle+bottom:center+right": {\n "chart": { "type": "line", "data": { "columns": ["Month", "Teams"], "rows": [["Jan", 12], ["Feb", 18]] } }\n }\n}\n```\n\nAnd the two-column shape from the grid above:\n\n```json\n{\n "title": "Operating Snapshot",\n "left": { "table": { "columns": ["Metric", "Value"], "rows": [["Revenue", "$4.2M"]] } },\n "center+right": { "chart": { "type": "line", "data": { "columns": ["Month", "Revenue"], "rows": [["Jan", 3.4]] } } }\n}\n```\n\nRegion keys on one slide must not overlap, and regions cannot be mixed with a root payload. Slide-level strings `title`, `subtitle`, and `tag` sit alongside whichever content shape you use, and render into the matching placeholders of the resolved layout.\n\n## Layouts are hints, not contracts\n\n`Slide.layout` optionally references a layout record. The layout\'s placeholders describe what the layout *exposes* (a title slot, chart regions, image treatment) \u2014 they do not constrain what the slide may contain. This loose coupling is intentional:\n\n- A slide may use any region keys or payloads regardless of its declared layout. Validators do not error on a slide/layout mismatch.\n- When `layout` is omitted, engines compose the slide automatically from its payload or region keys; that is not a finding.\n- A layout reference that resolves nowhere is the warning `opf/unresolved-reference`, and the slide composes automatically. Engines never substitute a different layout.\n\nThe principle, used throughout OPF: **slides are the source of truth**. Layouts, narratives, and design records guide rendering; they never invalidate content.\n\n## Narrative is a plan; the deck holds a pointer\n\nA narrative is a **plan**: a story arc, its ordered **beats** (labeled segments such as `hook`, `problem`, `evidence`, `ask`) and what each beat must do. A deck is the **product**. So the plan is a narrative record and the deck holds only a pointer: `narrative` is a reference, a bare id (`"classic-story"`, `"pitch-deck"`) or `name:id` for a record of a named catalog group. A beat carries its blueprint: `type` is the slide\'s `Slide.type`, `layout` is its `Slide.layout`, and `instructions` and `thoughtCues` guide the author. A record\'s `duration { min, max }` is the talk length in minutes it suits.\n\nSlides link themselves to beats with `Slide.beat`. Nothing forces them to, and the preview and the PPTX export draw nothing from the narrative. `validate` warns about a `slides[].beat` id the narrative does not define (`opf/unknown-beat`) and about a root `duration` outside the narrative\'s range (`opf/duration-outside-narrative`), and reports a beat that no slide references (`opf/unused-beat`, info).\n\nA narrative the document defines itself is a record in `catalogs.custom.narratives`, and `narrative` names its key. There is one shape, the record:\n\n```json\n{\n "name": "Schema Pitch",\n "narrative": "technical-proof",\n "duration": 12,\n "catalogs": {\n "custom": {\n "narratives": {\n "technical-proof": {\n "name": "Technical Proof",\n "duration": { "min": 8, "max": 20 },\n "beats": [\n { "id": "contract", "name": "Contract", "type": "text", "instructions": "State what stays stable." },\n { "id": "evidence", "name": "Evidence", "type": "chart" },\n { "id": "adoption", "name": "Adoption", "type": "list" }\n ]\n }\n }\n }\n },\n "slides": [\n { "beat": "contract", "title": "The Contract", "text": "Beats describe intent without constraining slides." },\n { "beat": ["evidence", "adoption"], "title": "Proof And Ask", "items": ["One slide may cover several beats."] }\n ]\n}\n```\n\nThe plan is also a skeleton deck: a planner emits slides that carry `beat`, `type`, `layout`, `title` and `notes`, which is a valid deck from the start. Deck-level concerns that are not part of the storyline (`audience`, `tone`, `takeaway`, `duration`) live as siblings on the presentation root.\n\n## Catalog references and how they resolve\n\nReusable design and intent records live in **catalogs**: a layout, a theme, a colour scheme, a font scheme, a narrative, an audience, a purpose or a tone. The referencing fields are `Slide.layout`, `design.theme`, `design.colorScheme`, `design.fontScheme` (and the same three on `Slide.design`), `narrative`, `audience`, `purpose` and `tone`, plus the `colorScheme` and `fontScheme` a theme record names and the font schemes of a `language` object.\n\nA reference is a bare id (`"two-column"`) or `name:id` (`"acme:hero"`), where the prefix names a group of the document\'s `catalogs`. URLs and `pkg:` strings are not references. A document\'s `catalogs` groups the records it embeds by the catalog they came from:\n\n```json\n"catalogs": {\n "default": { "source": "https://www.pptx.gallery", "layouts": { "two-column": { "name": "Two column", "placeholders": [{ "type": "title" }, { "type": "text" }, { "type": "text" }] } } },\n "acme": { "source": "pkg:@acme/opf-catalog", "themes": { "brand": { "name": "Brand", "colorScheme": "ocean", "fontScheme": "inter" } } },\n "custom": { "layouts": { "q4-special": { "name": "Q4 special", "placeholders": [{ "type": "title" }, { "type": "chart" }] } } }\n}\n```\n\n- `default` is the catalog bare ids come from. Leave it out to use the host\'s default catalog; `"default": false` turns the fallback off, so every bare id must be embedded.\n- `custom` holds the records the document defines itself. It has no source.\n- Any other name is an extra catalog, identified by its `source` (an HTTPS URL or a `pkg:` reference), and is the prefix its references use.\n- Embedded records carry no `$schema` or `id`: the key is the id.\n\nEvery reference resolves the same way, first match wins:\n\n```\n "layout": "two-column" "layout": "acme:hero"\n | |\n v v\n 1. catalogs.custom.layouts 1. catalogs.acme.layouts\n | miss | miss\n v v\n 2. catalogs.default.layouts 2. the catalog the host registered\n | miss for catalogs.acme.source\n v | miss\n 3. the catalog the host registered v\n for catalogs.default.source opf/unresolved-reference\n (the host default when default\n is omitted; nothing when false)\n | miss\n v\n opf/unresolved-reference\n```\n\nA reference written inside an embedded record resolves in that record\'s own group first: acme\'s `brand` theme finds acme\'s `ocean` colour scheme before the default catalog\'s. A reference that resolves nowhere is the warning `opf/unresolved-reference`, which names the reference and the catalog\'s source; the slide composes automatically and the design uses the [engine defaults](../spec/reference/engine-defaults.json). A strict export fails instead. A prefix that names no group is a validation error.\n\nEngines never fetch a catalog. Core ships no records in its main entry: a host registers the catalogs it trusts (`{ catalogs: [defaultCatalog, acmeCatalog] }`, where `defaultCatalog` comes from `@openpresentation/opf/catalog`), and authoring tools embed every record a document uses when they save it (`embed`), so a saved document renders the same anywhere, offline. See [the default catalog](default-catalog.md).\n\nTwo reference forms are accepted:\n\n- **A reference string** for the common case: `"narrative": "classic-story"`, `"theme": "acme:brand"`.\n- **Object form** for overrides on a referenced record (colour schemes, font schemes, audiences, purposes and tones): `{ "id": "cool-horizon", "accent1": "#0F4C81" }` resolves the record as a base, then inline fields win per key.\n\n`chart.type`, the `language` tag and the platform keys of `socials` are **engine vocabularies**, not catalog references: the schema validates them directly, and their labels and descriptions are catalog display metadata.\n\nA document can carry its own records, and take records from a company catalog by name:\n\n```json\n{\n "name": "Branded Deck",\n "design": { "colorScheme": "acme-brand" },\n "narrative": "acme:quarterly-arc",\n "catalogs": {\n "custom": {\n "colorSchemes": { "acme-brand": { "name": "Acme Brand", "accent1": "#0F4C81", "light1": "#FFFFFF", "dark1": "#0B1B2B" } }\n },\n "acme": {\n "source": "https://catalogs.example.com",\n "narratives": { "quarterly-arc": { "name": "Quarterly arc", "beats": [{ "id": "result", "name": "Result" }] } }\n }\n },\n "slides": [{ "title": "Branded Deck", "beat": "result" }]\n}\n```\n\n## Design in one paragraph\n\n`design` selects a `theme` (which bundles default color scheme, font scheme, background, and dimensions) and may override any of those directly; `Slide.design` overrides the deck design per slide. More specific always wins, field by field. Color schemes and font schemes each support two mixable models \u2014 OOXML slots/pairs that round-trip to PowerPoint, and abstract roles (`primary`, `heading`, `code`, \u2026) that engines map onto slots. Content color fields (rich-text runs, styled table cells) reference the design system by name \u2014 a scheme slot (`accent2`), a role (`text`), or a `var:<id>` entry from the top-level `variables` map \u2014 so styled content follows a re-theme instead of freezing hex values. The full precedence chain with worked examples is in [`design-resolution.md`](./design-resolution.md).\n\n## Assets\n\nBinary content lives in the top-level `assets` registry, keyed by id. Content payloads and design fields reference entries with `asset:<id>` strings; asset `src` values accept HTTPS URLs, data URIs, and paths resolved against the OPF file location.\n\n## A complete small deck\n\nEverything above, together \u2014 intent metadata, a catalog-backed narrative with beats, design, an organization and speaker, an asset-backed chart, regions, notes, and sections:\n\n```json\n{\n "$schema": "https://openpresentation.org/schema/opf/v1",\n "name": "Q3 Business Review",\n "description": "Quarterly review for the executive team.",\n "audience": "executive",\n "purpose": "decide",\n "tone": "formal",\n "language": "en-US",\n "narrative": "qbr",\n "takeaway": "Approve the expanded rollout budget.",\n "duration": 20,\n "organization": {\n "id": "acme",\n "name": "Acme Corp",\n "domain": "acme.com",\n "socials": { "linkedin": "acme" }\n },\n "speaker": { "id": "alice", "name": "Alice Chen", "title": "VP Operations", "organizationId": "acme" },\n "design": {\n "theme": "classic",\n "colorScheme": "forest-green",\n "footer": { "left": { "organization": true }, "right": { "slideNumber": true } }\n },\n "assets": {\n "adoption-csv": { "src": "./data/adoption.csv", "alt": "Monthly adoption data" }\n },\n "slides": [\n {\n "layout": "title",\n "beat": "objectives",\n "title": "Q3 Business Review",\n "subtitle": "Operations \u2014 October 2025"\n },\n {\n "beat": "performance-headline",\n "title": "Adoption Doubled",\n "left": { "metric": { "value": "2.1x", "label": "Quarter-over-quarter adoption", "trend": "up" } },\n "center+right": {\n "chart": { "type": "line", "data": { "columns": ["Month", "Active Teams"], "rows": [["Jul", 8], ["Aug", 12], ["Sep", 17]], "source": { "src": "asset:adoption-csv" } } }\n },\n "notes": "Pause here; this is the slide the decision hangs on."\n },\n {\n "beat": "risks",\n "section": "Decision",\n "title": "What Could Go Wrong",\n "items": [\n "Capacity: two regions are at 85% utilization.",\n {\n "text": "Churn risk in the legacy tier.",\n "description": "Mitigation: migration incentives ship in November."\n }\n ]\n },\n {\n "beat": "asks",\n "title": "The Ask",\n "text": "Approve $1.2M to expand the rollout to all regions in Q4."\n }\n ]\n}\n```\n\nThe beat ids (`objectives`, `performance-headline`, `risks`, `asks`) come from the `qbr` narrative record; the theme, color scheme and layout resolve in the default catalog the host registers (or in the records the saved deck embeds), and the chart type is an engine vocabulary. For a fixture that exercises the full surface in one file, see [`examples/technical/full-feature-tour.opf.json`](../examples/technical/full-feature-tour.opf.json).\n\n## Validation philosophy\n\nTwo layers, with a deliberate split:\n\n- **Schema errors** for structural problems: wrong types, overlapping region keys, payloads mixing incompatible content kinds, a region payload missing concrete content, duplicate slide or payload ids.\n- **Warnings** for advisory drift: references that resolve nowhere (`opf/unresolved-reference`), unknown `var:` variable references and unrecognized run colors, narrative/slide mismatches. These never make a document invalid.\n\n`validate` from `@openpresentation/opf` applies both layers locally and, beyond them, checks references, accessibility, layout and content ([validate](validate.md)).\n\n## Where to go next\n\n- [`schema-reference.md`](./schema-reference.md) \u2014 every field of every object in the presentation schema.\n- [`catalog-schema-reference.md`](./catalog-schema-reference.md) \u2014 every field of every catalog record schema.\n- [`content-payloads.md`](./content-payloads.md) \u2014 payload shapes and inference rules with examples.\n- [`design-resolution.md`](./design-resolution.md) \u2014 the design precedence algorithm.\n- [`examples.md`](./examples.md) \u2014 guide to the example decks under `examples/`.\n\nThen write a deck, commit it, revise it, and read the diff. A two-line diff for a two-word change is the whole argument for the format.\n'
194
194
  },
195
195
  {
196
196
  "slug": "image-treatments",
197
197
  "file": "docs/image-treatments.md",
198
- "title": "Image treatments",
199
- "markdown": "# Image treatments\n\nThis page covers `design.slideImage`: the slide-level image and the treatments that both coordinated engines draw the same way. opf-render draws them as SVG and opf-pptx exports them as native PowerPoint DrawingML. It also maps each of the 15 [pptx.gallery image treatments](https://www.pptx.gallery/image-treatments) to OPF. Each mapping is either supported, partial or unsupported, with the reason. The reference deck [`fixtures/image-treatments.opf.json`](fixtures/image-treatments.opf.json) has one slide per gallery treatment, and `pnpm test:slide-images` checks it across core, SVG and PPTX.\n\n## When a slide image applies\n\nCore composition (`composeSlide`) resolves `design.slideImage` into `geometry.slideImage`. Both engines draw exactly that frame. It applies to a slide in three cases:\n\n- The slide sets its own `design.slideImage`.\n- The deck sets it and the slide's layout record sets `design.slideImage`.\n- The deck sets it and the slide's root `image` is the same source.\n\nWhen the slide's root `image` is the same source, or when the treatment has no `src`, the root image becomes the slide image instead of a content item. Other slides ignore a deck-level value. See [dynamic composition](dynamic-composition.md#slide-level-images).\n\n## Treatment vocabulary\n\nEvery property is optional and additive on the `{ position, ... }` object form.\n\n| Property | Meaning | Native PPTX | SVG preview |\n|---|---|---|---|\n| `position` | `background`, or a band along `left`/`right`/`top`/`bottom`. Headings and content compose in the rest of the slide. | `p:pic` beneath all content | `<image>` beneath branding and content |\n| `size` | Band share of the slide, 0.1 to 0.9. Default 0.5. | frame geometry | frame geometry |\n| `inset` | Frame inside the slide padding, like a card. | frame geometry | frame geometry |\n| `aspectRatio` | Largest centered frame with this width/height ratio (`circle` uses 1). | frame geometry | frame geometry |\n| `fill` | `crop` covers the frame from the center; `fit` centers the whole image. Default `design.imageFill`, else `crop`. | `a:srcRect`: positive insets crop, negative insets pad | `preserveAspectRatio` `slice` / `meet` |\n| `shape` | `rectangle`, `rounded`, `circle`, `hexagon` | `a:prstGeom` `rect` / `roundRect` / `ellipse` / `hexagon` with core's guide values | `clipPath` with core's outline of the same preset formula |\n| `cornerRadius` | `rounded` radius as a share of the shorter side. Default 0.16667. | `roundRect` `adj` | circular arcs with the same radius |\n| `border` | `{ color, width }`. The line is centered on the outline, and its width is in reference pixels. | `a:ln` with `solidFill`, `algn=\"ctr\"` and a miter join | `stroke` on the same outline, miter join |\n| `opacity` | Image opacity, image pixels only. | `a:alphaModFix` | `opacity` on the image only |\n| `recolor` | `\"grayscale\"`, or `{ dark, light }` for duotone. Luminance uses Rec. 601 weights on sRGB. | `a:grayscl` / `a:duotone` | `feColorMatrix` in sRGB |\n| `overlay` | `{ color, opacity, edge?, size? }` scrim in the frame's shape, or an edge band on a rectangle frame. | one `p:sp` directly above the picture | `path` with `fill-opacity` |\n| `alt` | Alternative text. | `descr` | `aria-label` |\n\nColors accept the usual `ColorRef` forms: hex, scheme slot or role, or `var:<id>`. Eight-digit hex alpha is kept as native `a:alpha` and SVG opacity. The paint order is the same in both engines: image with its recolor and opacity, then the line, then the overlay. An edge overlay on a non-rectangle frame reports `unsupported-image-treatment` at `...slideImage.overlay.edge`, and neither engine draws it. That diagnostic never fails strict layout.\n\nAn unchanged export imports back as the slide's `design.slideImage`: the treatment, plus a data URI of the embedded image. `OPF_SLIDE_IMAGE_V1` and `OPF_SLIDE_IMAGE_OVERLAY_V1` shape tags record the native geometry. Changes are handled like this:\n\n- A picture that was moved, re-cropped or recolored stays an ordinary image and reports `invalid-slide-image-provenance`.\n- An edited overlay drops only the overlay.\n\n## pptx.gallery treatments\n\n| Gallery treatment | OPF `design.slideImage` (plus slide design) | Status | Reason or gap |\n|---|---|---|---|\n| full-bleed | `{position: background, fill: crop, overlay: {color: dark1, opacity: 0.2}}` | supported | |\n| text-overlay | `{position: background, fill: crop, overlay: {color: dark1, opacity: 0.55}}` | supported | Heading color still follows the theme background. Use a dark theme or background for light text over photos. |\n| side-by-side | `{position: left, size: 0.46, fill: crop}` | supported | |\n| caption-overlay | `{position: background, inset: true, fill: crop, overlay: {color: dark1, opacity: 0.75, edge: bottom, size: 0.25}}` | supported | Square corners. An edge band cannot follow a rounded mask as one native shape. |\n| masked-shape | `{position: right, inset: true, shape: hexagon, aspectRatio: 1.1547, fill: crop}` | supported | Masks are limited to the four presets. Arbitrary polygon and custom-geometry masks are not in the vocabulary. |\n| circular-crop | `{position: left, size: 0.4, inset: true, shape: circle, fill: crop}` | supported | The gallery's small shadow is not drawn (see shadows). |\n| rounded-card | `{position: background, inset: true, shape: rounded, cornerRadius: 0.05, fill: crop, border: {color: accent5, width: 1}}` | supported, without shadow | The optional card shadow is unsupported (see shadows). |\n| duotone | `{position: background, fill: crop, recolor: {dark: accent1, light: light1}}` | supported | Native raster parity of PowerPoint's luminance weights is unverified (see fidelity). |\n| background-blur | `{position: background, fill: crop, overlay: {color: light1, opacity: 0.4}}` plus `contentBox: true` | **unsupported: blur** | Blur is not expressible. The mapping keeps the light scrim and the sharp card, without the blur. |\n| image-strip | `{position: bottom, size: 0.3, fill: crop}` | partial | A slide image is one picture. A strip of several images is content: blocks with image payloads in a row composition, which already export as native cropped pictures. |\n| collage-grid | no slide image: 4 `image` blocks, `composition: {mode: grid, columns: 2}`, `imageFill: crop` | supported via content blocks | A collage is content composition, not a single-image treatment. Content images export as native pictures. Reimport keeps the pictures but reports `unsupported-image-crop` for their crops. |\n| device-frame | `{position: right, inset: true, aspectRatio: 0.5, shape: rounded, cornerRadius: 0.12, fill: crop, border: {color: dark1, width: 12}}` | partial | The bezel is a thick line on a rounded portrait frame. Device artwork (notch, buttons, device-specific screens) is not expressible. |\n| cutout-subject | `{position: right, fill: fit}` with a transparent PNG | partial | **Background removal is unsupported.** PowerPoint's Remove Background produces an edited bitmap, not a declarative effect. Supply a pre-cut transparent PNG, and both engines keep its alpha. The grounding shadow is unsupported (see shadows). |\n| watermark | `{position: background, inset: true, fill: fit, opacity: 0.1, recolor: grayscale}` | supported | `design.watermark` remains a separate, preview-only feature. |\n| cinematic-crop | `{position: background, aspectRatio: 2.39, fill: crop}` with `design.background: dark1` | supported | |\n\nWith a real image, 14 treatments are distinct OPF documents. Collage-grid is block content, not a slide-image treatment. The gallery snippets themselves live in pptx.gallery (FF-30).\n\n## Unsupported effects\n\nThese effects are left out of the vocabulary on purpose. A value that both engines cannot draw identically would break the preview/export contract.\n\n- **Blur** (`a:blur` inside a blip, or the Office 2010 `a14:imgEffect` artistic blur): PowerPoint's blur kernel and radius scale are not specified. There is also no native evidence that PowerPoint renders a blip `a:blur` on pictures. An SVG `feGaussianBlur` cannot be shown to match. background-blur is therefore unsupported.\n- **Shadows and soft edges** (`a:outerShdw`, `a:softEdge` in `a:effectLst`): these are native, but PowerPoint's blur kernel for them is unspecified. An SVG drop shadow would only approximate it.\n- **Background removal**: this is a bitmap edit, not an effect. Use a transparent source.\n- **Device artwork and arbitrary masks**: these would need custom geometry and composed artwork. The treatment vocabulary is limited to preset masks and a line.\n- **Tint, brightness and contrast** (`a:lum`, `a:clrChange`, `a:tint`): no gallery treatment needs them. They are left out until they have the same luminance-formula verification as grayscale and duotone.\n\n## Fidelity boundary\n\nThese checks prove the shared contract: geometry, preset guides and outlines, line width and color, recolor endpoints, alpha, overlay and round trip (`pnpm test:slide-images`, plus the tests in each engine). They do not prove PowerPoint raster parity. Office runs are root-only, and the following are still unverified in native PowerPoint:\n\n- negative `a:srcRect` insets for `fit`;\n- the luminance weights PowerPoint uses for `a:grayscl` and `a:duotone`. The preview uses Rec. 601 on sRGB, as LibreOffice does.\n- line joins on the hexagon outline.\n"
198
+ "title": "Images: backgrounds, image blocks and treatments",
199
+ "markdown": '# Images: backgrounds, image blocks and treatments\n\nOPF 0.15 has two places for a picture, with one fit vocabulary:\n\n- A **background** is the slide\'s canvas fill. A picture background fills the whole slide behind everything and never moves content.\n- An **image block** holds every content picture. It carries fit, focus, the treatment vocabulary below, and an optional placement that bleeds it to one slide edge.\n\nopf-render draws both as SVG and opf-pptx exports them as native PowerPoint DrawingML, from the same core geometry. This page also maps each of the 15 [pptx.gallery image treatments](https://www.pptx.gallery/image-treatments) to OPF, with its support status and the reason. The reference deck [`fixtures/image-treatments.opf.json`](fixtures/image-treatments.opf.json) has one slide per gallery treatment. Core\'s `test/image-blocks.test.mjs` checks it against core 0.14\'s frozen picture geometry, and `pnpm test:image-treatments` checks it across core, SVG and PPTX.\n\n## Backgrounds\n\n```json\n{\n "design": {\n "background": {\n "type": "image",\n "src": "./images/harbour.jpg",\n "alt": "Container ships at dawn",\n "fit": "cover",\n "focus": { "x": 0.5, "y": 0.7 },\n "overlay": { "color": "dark1", "opacity": 0.4 }\n }\n }\n}\n```\n\nThe effective background is the slide\'s `design.background`, then the deck\'s, then the theme\'s. A picture background has these properties:\n\n| Property | Meaning |\n|---|---|\n| `src` | The picture: `asset:<id>`, an HTTPS URL, a data URI or a relative path. |\n| `alt` | Alternative text. With `alt` the picture is meaningful, and PPTX export draws it as a full-slide picture at the back that carries the text. Without `alt` it is decorative and exports as the native slide background. A referenced asset\'s alt text does not count. |\n| `fit` | `cover` (the default) fills the slide and crops around `focus`. `contain` shows the whole picture over the colour scheme\'s default background. `stretch` scales it to the slide. `tile` repeats it at its own size from the top-left corner. |\n| `focus` | `{ x, y }` in 0 to 1: the point of the picture that a `cover` crop keeps in view. The default is the center. |\n| `opacity` | Picture opacity. The overlay keeps its own. |\n| `recolor` | `"grayscale"`, or `{ dark, light }` for duotone, as on an image block. Recolor and opacity apply to the picture\'s pixels; the overlay is drawn above them. |\n| `overlay` | A scrim over the whole picture, or a band along one edge (`edge`, `size`; see [Overlay](#overlay)). |\n\nThe string shorthand `"asset:hero"` (or any source starting with `asset:`, `https://`, `data:`, `./` or `../`) is a cover picture background. Theme slots (`light1`) and hex colours keep their meaning, and any other string is invalid.\n\n`composeSlide` reports the picture as `SlideComposition.backgroundImage`: the canvas `box`, `fit`, `focus`, `opacity`, `recolor`, the overlay box, and the fit placement when core can read the picture\'s proportions. The headings and the body compose exactly as on a slide without the background. A slide with only a title over a picture background is a cover, and its title centers like any cover\'s.\n\n## Image blocks\n\n```json\n{ "image": { "src": "asset:hero", "alt": "Harbour" }, "fit": "cover", "focus": { "x": 0.3, "y": 0.5 }, "shape": "rounded", "border": { "color": "accent5", "width": 1 } }\n```\n\n`Slide.image` is source shorthand for one image block, with the effective `design.imageFit`: it takes no fit, focus, treatment or placement keys of its own (the schema rejects them on the slide). To treat or place a picture, write an image block in `blocks`. Every property below is optional, valid only on an image payload, and additive.\n\n| Property | Meaning | Native PPTX | SVG preview |\n|---|---|---|---|\n| `fit` | `cover` fills the frame and crops around `focus`. `contain` shows the whole picture. `stretch` scales it to the frame. Default: the effective `design.imageFit` (the slide\'s, the deck\'s, then the layout\'s), else `cover`. | `a:srcRect`: positive insets crop, negative insets pad | the shared placement (`fitImage`) |\n| `focus` | `{ x, y }`: the point a `cover` crop keeps in view. | `a:srcRect` | the same placement |\n| `aspectRatio` | The largest centered frame with this width/height ratio (`circle` uses 1). | frame geometry | frame geometry |\n| `shape` | `rectangle`, `rounded`, `circle` or `hexagon`. | `a:prstGeom` `rect` / `roundRect` / `ellipse` / `hexagon` with core\'s guide values | `clipPath` with core\'s outline of the same preset formula |\n| `cornerRadius` | Radius of a `rounded` frame as a share of the shorter side. Default 0.16667. | `roundRect` `adj` | circular arcs with the same radius |\n| `border` | `{ color, width }`. The line is centered on the outline, and its width is in reference pixels. | `a:ln` with `solidFill`, `algn="ctr"` and a miter join | `stroke` on the same outline, miter join |\n| `opacity` | Opacity of the picture\'s pixels only. | `a:alphaModFix` | `opacity` on the image only |\n| `recolor` | `"grayscale"`, or `{ dark, light }` for duotone. Luminance uses Rec. 601 weights on sRGB. | `a:grayscl` / `a:duotone` | `feColorMatrix` in sRGB |\n| `overlay` | A scrim in the frame\'s shape, or an edge band on a rectangle frame. | one `p:sp` directly above the picture | `path` with `fill-opacity` |\n| `placement` | `{ edge, size, inset }`: bleed the block to one slide edge (see below). | frame geometry | frame geometry |\n\nColors accept the usual `ColorRef` forms: hex, scheme slot or role, or `var:<id>`. Eight-digit hex alpha is kept as native `a:alpha` and SVG opacity. Alt text comes from the image\'s asset.\n\n`composeSlide` reports every image item\'s picture as `ComposedItem.image`: the `region` (the cell, or the placement band), the frame `box`, `fit`, `focus`, the `shape` with its DrawingML preset and SVG outline, `border`, `opacity`, `recolor`, `overlay` and `placement`. Both engines draw exactly that. The paint order is the same in both: the picture with its recolor and opacity, clipped to the shape, then the line, then the overlay.\n\n### Placement\n\n`placement: { edge: "left" | "right" | "top" | "bottom", size: 0.1\u20130.9 (default 0.5), inset: false }` gives the image a band along that edge of the slide, edge to edge, and the headings and other content compose in the rest of the slide at the normal padding. `size` is the band\'s share of the slide width (left, right) or height (top, bottom). `inset: true` puts the frame inside the slide padding of the band, like a card. Header and footer bands keep their full-width placement.\n\n- Only a top-level block (`slides.N.blocks.I`) can be placed, and each slide edge takes at most one placed image. A placed block inside a group or a promoted region, or a second block on the same edge, is an `opf/image-placement-invalid` error.\n- Bands are taken in block order. Each spans the free area left by the earlier ones.\n- A layout\'s image placeholder may carry the same `placement`. The layout\'s n-th image placeholder places the slide\'s n-th top-level image (an image block, or `Slide.image`) unless that block sets its own; this is the only way a root `Slide.image` is placed.\n- A slide whose only body is placed images is composed like a cover: its headings center in the free area beside the image.\n- In a right-to-left deck `left` is the start side, drawn at the right. Edge overlays mirror the same way.\n\nPlacement is the only mechanism that moves content aside. A background never does.\n\n## Overlay\n\n`{ color, opacity, edge?, size? }` is shared by backgrounds and image blocks. Without `edge` it covers the whole frame in the frame\'s shape. With `edge` (`top`, `bottom`, `left` or `right`) it covers a band of `size` (default 0.3) of the frame along that edge, for example a caption strip. An edge overlay on a non-rectangle image frame reports `unsupported-image-treatment` at `...overlay.edge`, and neither engine draws it. That diagnostic never fails strict layout. Text over a picture background is certified readable (`opf/text-on-image`) only through a full-frame overlay strong enough for every possible pixel; an edge band does not count.\n\nAn unchanged export imports back as the same OPF: the background and the image blocks with their treatments, plus a data URI of each embedded picture. The `OPF_IMAGE_V1` and `OPF_IMAGE_OVERLAY_V1` shape tags record the native geometry of an image block. A picture that was moved, re-cropped or recolored stays an ordinary image and reports its provenance diagnostic. An edited overlay drops only the overlay.\n\n## pptx.gallery treatments\n\n| Gallery treatment | OPF | Status | Reason or gap |\n|---|---|---|---|\n| full-bleed | background `{type: image, src, overlay: {color: dark1, opacity: 0.2}}` | supported | |\n| text-overlay | background with `overlay: {color: dark1, opacity: 0.55}` | supported | Heading color still follows the theme background. Use a dark theme or background for light text over photos. |\n| side-by-side | image block, `placement: {edge: left, size: 0.46}` | supported | |\n| caption-overlay | image block, `overlay: {color: dark1, opacity: 0.75, edge: bottom, size: 0.25}` | supported | Square corners. An edge band cannot follow a rounded mask as one native shape. The gallery\'s card sits inside the slide padding, which a background cannot do, so the picture is content below the headings. Without the inset it is a background with the same edge-band overlay. |\n| masked-shape | image block, `shape: hexagon, aspectRatio: 1.1547, placement: {edge: right, inset: true}` | supported | Masks are limited to the four presets. Arbitrary polygon and custom-geometry masks are not in the vocabulary. |\n| circular-crop | image block, `shape: circle, placement: {edge: left, size: 0.4, inset: true}` | supported | The gallery\'s small shadow is not drawn (see shadows). |\n| rounded-card | image block, `shape: rounded, cornerRadius: 0.05, border: {color: accent5, width: 1}` | supported, without shadow | The optional card shadow is unsupported (see shadows). |\n| duotone | background with `recolor: {dark: accent1, light: light1}` | supported | Native raster parity of PowerPoint\'s luminance weights is unverified (see fidelity). |\n| background-blur | background with `overlay: {color: light1, opacity: 0.4}`, plus `contentBox: true` | **unsupported: blur** | Blur is not expressible. The mapping keeps the light scrim and the sharp card, without the blur. |\n| image-strip | image block, `placement: {edge: bottom, size: 0.3}` | partial | A placed block is one picture. A strip of several images is content: image blocks in a row composition. |\n| collage-grid | 4 image blocks, `composition: {mode: grid, columns: 2}`, `imageFit: cover` | supported via content blocks | A collage is content composition, not a single-image treatment. |\n| device-frame | image block, `aspectRatio: 0.5, shape: rounded, cornerRadius: 0.12, border: {color: dark1, width: 12}, placement: {edge: right, inset: true}` | partial | The bezel is a thick line on a rounded portrait frame. Device artwork (notch, buttons, device-specific screens) is not expressible. |\n| cutout-subject | image block, `fit: contain, placement: {edge: right}`, with a transparent PNG | partial | **Background removal is unsupported.** PowerPoint\'s Remove Background produces an edited bitmap, not a declarative effect. Supply a pre-cut transparent PNG, and both engines keep its alpha. The grounding shadow is unsupported (see shadows). |\n| watermark | image block, `fit: contain, opacity: 0.1, recolor: grayscale` | supported | `design.watermark` remains a separate, preview-only feature. |\n| cinematic-crop | image block, `aspectRatio: 2.39`, with `design.background: dark1` | supported | |\n\nThe frames of full-bleed, text-overlay, background-blur, duotone, side-by-side, masked-shape, circular-crop, image-strip, device-frame, cutout-subject and collage-grid are those of core 0.14. Caption-overlay, rounded-card, watermark and cinematic-crop were 0.14 slide images behind the headings that need an inset, a mask, a line or an aspect ratio, which a background does not have. In 0.15 they are image blocks below the headings, and their treatment geometry within the frame is unchanged. The gallery snippets themselves live in pptx.gallery (FF-30).\n\n## Unsupported effects\n\nThese effects are left out of the vocabulary on purpose. A value that both engines cannot draw identically would break the preview/export contract.\n\n- **Blur** (`a:blur` inside a blip, or the Office 2010 `a14:imgEffect` artistic blur): PowerPoint\'s blur kernel and radius scale are not specified. There is also no native evidence that PowerPoint renders a blip `a:blur` on pictures. An SVG `feGaussianBlur` cannot be shown to match. background-blur is therefore unsupported.\n- **Shadows and soft edges** (`a:outerShdw`, `a:softEdge` in `a:effectLst`): these are native, but PowerPoint\'s blur kernel for them is unspecified. An SVG drop shadow would only approximate it.\n- **Background removal**: this is a bitmap edit, not an effect. Use a transparent source.\n- **Device artwork and arbitrary masks**: these would need custom geometry and composed artwork. The treatment vocabulary is limited to preset masks and a line.\n- **Tint, brightness and contrast** (`a:lum`, `a:clrChange`, `a:tint`): no gallery treatment needs them. They are left out until they have the same luminance-formula verification as grayscale and duotone.\n- **Frame treatments on backgrounds**: a background has fit, focus, opacity, recolor and an overlay (with edge bands). A masked, bordered, inset or aspect-ratio picture is an image block.\n\n## Fidelity boundary\n\nThese checks prove the shared contract: geometry, preset guides and outlines, line width and color, recolor endpoints, alpha, overlay and round trip (`pnpm test:image-treatments`, plus the tests in each engine). They do not prove PowerPoint raster parity. Office runs are root-only, and the following are still unverified in native PowerPoint:\n\n- negative `a:srcRect` insets for `contain`;\n- the luminance weights PowerPoint uses for `a:grayscl` and `a:duotone`. The preview uses Rec. 601 on sRGB, as LibreOffice does.\n- line joins on the hexagon outline.\n'
200
200
  },
201
201
  {
202
202
  "slug": "live-editor",
203
203
  "file": "docs/live-editor.md",
204
204
  "title": "Browser preview and live editing",
205
- "markdown": "# Browser preview and live editing\n\nPublished editor 0.12.1 provides an embeddable SVG canvas in `@openpresentation/opf-editor/canvas`. OPF JSON remains the document; the canvas writes validated JSON Patch operations through an `EditorSession`. Draft edits render with the same SVG engine used for standalone previews. Completed edits produce one undoable change.\n\nThe published canvas covers the interactions below; complete PowerPoint feature coverage remains separate work. \u201CPixel perfect\u201D is a fidelity target with specific prerequisites and remaining gaps described below.\n\n## Install the published packages\n\nUse Node 24 with core 0.13.0, renderer 0.13.1, editor 0.12.1 and PPTX 0.13.2:\n\n```sh\nnpm install --save-exact @openpresentation/opf@0.13.0 @openpresentation/opf-render@0.13.1 @openpresentation/opf-editor@0.12.1 @openpresentation/opf-pptx@0.13.2\n```\n\nNo paid service or provider account is required. The six agent skills install with `npx @openpresentation/cli@0.11.0 skills install`. See the [quickstart](quickstart.md) for an installed-package workflow and the [compatibility matrix](compatibility-matrix.md) for separately scoped browser and native evidence.\n\nFor library development, separately regenerate unpublished local preview tarballs from sibling checkouts:\n\n```sh\npnpm build\nnode scripts/link-ecosystem.mjs\npnpm pack:ecosystem\npnpm test:packed-ecosystem\n```\n\nThe packed consumer installs actual tarballs without workspace aliases, exercises editing/SVG/PPTX, checks TypeScript declarations, and bundles a browser entry without Node shims. For a public release, advance source versions and downstream minimums/lockfiles together and follow the release process.\n\nThe gallery host example also offers local PPTX file import with preview/diagnostics and editable PowerPoint download. It commits active canvas text before export, shares preview text measurements and applies imports as a single undoable change. Save OPF to preserve the original source; native PowerPoint positions, fonts and unsupported features can change during conversion. The browser E2E checks run offline after loading and inspect the downloaded native merged table, then reimport and undo/redo. Native edit/save/reopen is a separate targeted check, not a pixel-equivalence claim.\n\n`pnpm prepare:gallery:registry` builds host controls from the immutable `exampleRefs.opf-editor` in `release-plan.json` while resolving libraries only from the fresh npm consumer. Package `verificationRefs` continue to point at actual published releases. The gallery manifest records both the example source hashes and registry package integrities. Updating example controls does not imply a new editor library release.\n\nScript fonts: when the pinned editor example loads faces from `./script-fonts/` and the pinned renderer has the script pack (0.10.0 and later), the registry build also writes `script-fonts.json` and lists its hash in `manifest.json`. The manifest is the reviewable half: every `@expo-google-fonts/noto-*` package, exact version, SPDX license, license-file hash and each face's SHA-256, taken from the published renderer. The faces are binaries (63 files, 66.9 MiB), so they are never committed to the gallery repository. The gallery build copies them from its own pinned npm dependencies into the untracked `public/opf-editor/script-fonts/` directory, verifying every hash, and writes the license notices beside them; nothing is fetched from a font CDN. See `scripts/gallery-script-fonts.mjs` and the gallery's `scripts/prepare-editor-script-fonts.mjs`.\n\nBase fonts (FF-41): when the pinned editor example loads `base-fonts.json` (opf-editor 0.10.5: the example passes the faces as the renderer's `extraLazyFonts`, renderer 0.11.7 and later; 0.10.4's example used its own `examples/base-font-gate.js`), the registry build starts the editor with Roboto Regular alone in `fonts.json` (217 KB instead of 12.8 MB) and writes every other eager face (Roboto in six more styles, Roboto Mono, the Office substitutes) as a separate file named after its hash beside it, listed with its SHA-256 in `base-fonts.json` and in `manifest.json`; the editor fetches only the faces a document draws, verified, through its font gate (`scripts/gallery-base-fonts.mjs`). An older pinned example keeps every eager face in `fonts.json`. A default Roboto deck loads about 0.7 MB of fonts instead of 12.8 MB.\n\nLazy fonts: when the pinned editor example calls `ensureLazyFonts` and the pinned renderer vendors faces (Intos for the default Aptos scheme and the open families, renderer 0.11.0 and later), the registry build also writes `lazy-fonts.json` and lists its hash in `manifest.json`. It pins every vendored package (exact version, SPDX license, license-file and notice hashes) and each face SHA-256, taken from the published renderer. The faces are binaries, so they are not committed either: the gallery build copies them from its pinned `@openpresentation/opf-render` package (`fonts/<name>/`) into the untracked `public/opf-editor/fonts/` directory, verifying every hash, and the editor fetches only the families a document uses, same-origin. See `scripts/gallery-lazy-fonts.mjs` and the gallery's `scripts/prepare-editor-lazy-fonts.mjs`.\n\nThe browser bundle links `playground.js.LEGAL.txt`, included in the hashed resources. It contains bundled license notices and package license files, including the vendored PptxGenJS MIT license. For dependencies that publish only an explicit MIT declaration in their README, the build retains that declaration/attribution and the standard terms; omitted upstream notices use a version-specific source URL and verified supplement hash. License collection runs offline from the verified installation and committed supplement. Runtime JavaScript is not rewritten to normalize comment whitespace.\n\n## Embed in any browser application\n\nMount after the host DOM exists. The container controls width; the slide retains its aspect ratio. React and Svelte applications can mount this framework-independent API in their normal client lifecycle and destroy it on unmount.\n\n```js\nimport { createCanvasEditor } from '@openpresentation/opf-editor/canvas';\nimport { loadBrowserFontRegistry } from '@openpresentation/opf-render/fonts-browser';\n\n// Copy these licensed font files into your application's static assets first.\n// Use pinned, static faces; include every weight/style required by your deck.\nconst fonts = await loadBrowserFontRegistry([\n { url: '/fonts/Roboto-Regular.ttf', family: 'Roboto', weight: 400 },\n { url: '/fonts/Roboto-Bold.ttf', family: 'Roboto', weight: 700 },\n { url: '/fonts/RobotoMono-Regular.ttf', family: 'Roboto Mono', weight: 400 },\n]);\n\nconst canvas = createCanvasEditor(document.querySelector('#slide'), {\n document: {\n design: { theme: 'classic', fontScheme: 'roboto' },\n slides: [{ title: 'An editable presentation', text: 'Click to edit.' }],\n },\n renderOptions: { textMeasurement: fonts.textMeasurement },\n onCommit: ({ editor }) => {\n const updatedOPF = editor.document; // Host owns saving and collaboration.\n console.log(updatedOPF);\n },\n onError: error => console.error(error.message),\n});\nawait canvas.ready;\n\n// JSON or LLM patches also update the slide automatically.\ncanvas.editor.set('slides.0.title', 'Changes from another control');\ncanvas.editor.undo();\n\n// On unmount:\n// canvas.destroy();\n// fonts.dispose();\n```\n\n`loadBrowserFontRegistry` accepts explicit font-file URLs or `Uint8Array` data. It uses the same bytes for Fontkit measurement and browser `FontFace` registration, awaits loading, reports failures, and exposes `dispose()` for its owned font faces. Cross-origin font URLs need CORS access. Load fonts once and share the registry between canvases. The canvas does not fetch fonts or catalog sources itself.\n\nFor standalone SVG export, pass `fonts.embeddedFonts` to `renderSvg`; the export carries the font bytes and supplied license metadata. In a running browser canvas the registered fonts are already available, so embedding those bytes into every draft is unnecessary.\n\n```js\nimport { renderSvg } from '@openpresentation/opf-render/svg';\nconst svg = renderSvg(canvas.editor.document, {\n textMeasurement: fonts.textMeasurement,\n embeddedFonts: fonts.embeddedFonts,\n});\n```\n\nThe explicit `/svg` entry is browser safe. Browser-aware bundlers also select it for the renderer's root import. The Node root entry additionally supplies `svgToPng` and `svgToPdf`; those functions are not browser APIs.\n\n## Editing behavior\n\n| Content or action | Current behavior |\n| --- | --- |\n| Titles, subtitles, plain text, simple numeric values | One click enters editing with the caret at the clicked character (editor 0.10.2); press-drag selects a range; while editing, double-click selects a word and triple-click a paragraph. Focus a target and press Enter, Space or F2 to edit with all text selected. See *Text entry gestures* below. |\n| Table headers and string/number cells | Inline editing; numeric cells keep their numeric type. |\n| Lists, charts, metrics, quotes, code, timelines, rich text payloads | Select the object and edit its existing scalar fields in a floating form; valid drafts render immediately. A timeline event's `status` (`done`, `current`, `planned`) is a menu, not free text. |\n| Images | Edit source/alt fields; replace with a local PNG/JPEG/GIF/WebP file up to 20 MB. External sources still require a host image resolver. |\n| Collections | Add or remove the last item, subject to OPF schema validation. Empty structured collections may need authoring through source. |\n| Dynamic layout | Text edits recompose the slide through shared geometry; row/column/grid controls remain in the demo inspector. |\n| Undo and cancellation | Blur or Ctrl/Cmd+Enter commits plain text; Escape cancels; property forms have Apply/Cancel. |\n| Changes elsewhere | Unrelated edits are preserved; a changed selected payload cancels the stale local draft instead of overwriting it. This is conflict protection, not a distributed collaboration protocol. |\n| JSON editing | The demo Source view previews valid JSON beside the source; Apply records the document replacement. Invalid drafts retain the last valid preview. |\n\n`createCanvasEditor` accepts an existing `editor` session or a `document`, plus `slideIndex`, `renderOptions`, `textEntry` (`'click'` by default, or `'dblclick'`), an optional empty `propertiesContainer` to dock forms outside the slide, and callbacks `onSelect`, `onDraft`, `onCommit`, `onCancel`, `onRender`, and `onError`. The returned object exposes `editor`, `ready`, `select`, `beginEdit`, `editProperties`, `commit`, `cancel`, `setSlide`, `setRenderOptions`, `setLayoutEditing`, `render`, and `destroy`. `commit()` and setters return false if a draft cannot be committed. Avoid using public `render(document)` as a second source of truth; normal document changes should flow through the session.\n\n## Text entry gestures\n\nEditor 0.10.2 follows the PowerPoint and Google Slides convention. Hover outlines a text target. A single press (mouse, pen, or a touch tap) on editable text selects the box, starts inline editing and puts the caret at the nearest character boundary to the pointer, including in wrapped, multi-line, centered, right-aligned, right-to-left and CJK text. Press and drag selects the range from the press point to the release point and never moves the box. While editing, a native double-click selects a word, a triple-click a line or paragraph, and a click elsewhere moves the caret. Clicking a different text target commits the current edit (an invalid edit still refuses) and enters the new target in the same click. Rich text uses the same gestures through its own pointer mapping.\n\nKeyboard entry keeps the replace convention: focus a target and press Enter, Space or F2 to edit with **all** text selected; `canvas.beginEdit(path)` does the same. Escape leaves editing and keeps the box selected. Images, video, charts and other non-text targets are unchanged: a click selects and a double-click opens their properties. Layout handles and block controls keep their own pointer handling.\n\n`createCanvasEditor(container, { textEntry: 'dblclick' })` keeps the older two-step gesture (a click selects, a double-click enters), but the double-click now places the caret at the pointer instead of selecting everything. Tests and hosts that used `dblclick()` and then relied on all text being selected should enter with the keyboard (focus the target, press Enter) or select explicitly; on the default canvas `dblclick()` now places the caret and selects the word under it. Carets are resolved from the traced SVG glyphs (each rendered line carries its source range) and converted to offsets in the input value, so CRLF sources, tabs and wrapped whitespace map exactly; real operating-system IME and bidi caret behavior are not verified.\n\n## Fidelity contract and remaining work\n\nThe same document, renderer version, dimensions, font bytes, and measurement provider produce the same SVG geometry in read and edit modes. Inline editing retains the actual SVG glyphs beneath a transparent native input; the input supplies the caret and selection. Browser regression checks compare draft text positions to standalone SVG rendering.\n\nThat is not a promise of identical raster pixels across browser engines, operating systems, or PowerPoint. Native caret/selection wrapping can differ from shaped SVG text, especially for mixed scripts, rich text, or unusual font features. Browser anti-aliasing and native PowerPoint typography also differ. Without a measurement provider the renderer uses deterministic estimates, which are not sufficient for a high-fidelity claim.\n\nStill needed for the requested complete editor:\n\n1. Continuous mixed-style typing and calibrated caret positioning, bidi/IME/vertical-script coverage. Rich text selection, formatting, links, and selected-text replacement are available through the [SVG formatting toolbar and range API](rich-text.md).\n2. More placement constraints and specialized interactions for fixed promoted regions and individual object geometry. **Add content** and **Arrange** already support the insertion, duplication and deletion described below, track resizing, sibling block dragging, and moving complete blocks between existing groups or slides.\n3. Full visual implementations for specialized charts, media playback, image crops/effects, theme chrome, and every catalog preset. Generic property editing does not imply complete renderer support.\n4. Approved screenshot baselines across representative fonts/layouts/browsers, vertical metric tests, and native PPTX comparison/embedding work.\n5. Broader font-family/script coverage and independently loadable font packs; the current base and Office substitute packs do not cover every requested font. Published packages, documentation examples and installed-package browser CI already exist.\n\nGoogle Fonts supports browser loading through its CSS API, and its repository permits self-hosting subject to each font's license. The OPF fidelity path uses pinned files for reproducibility instead of depending on whichever variant a hosted stylesheet returns. Keep the font's accompanying license. Sources: [Google Fonts CSS API](https://developers.google.com/fonts/docs/css2), [Google Fonts files and licenses](https://github.com/google/fonts/blob/main/README.md).\n\nThe [font roadmap](plans/font-roadmap.md) covers the starter Office substitutes and remaining families.\n\n## Verification\n\n`pnpm demo:editor` builds the playground and `/canvas-tests.html`. The browser harness exercises real font registration, live drafts, text-position parity, one-step undo, cancellation, external edit conflicts, number validation, table cells, structured payloads, collection changes, and cleanup. Node tests cover escaped field paths, typed values, immutable drafts, font loader failures and aborts. The renderer's 126-deck corpus and the coordinated CI's pinned furniture PNG baseline are separate checks. Current installed-package and browser results are recorded in the [compatibility matrix](compatibility-matrix.md); neither those checks nor historical rasters establish general native Office parity.\n\n## Copy, paste, files, and galleries\n\nThe demo's **Copy OPF** dialog exports the whole presentation, the current slide with its design/catalogs/assets, or the selected JSON value. Choose readable JSON, compact JSON, or a Markdown code block for an LLM. The slide toolbar and selection inspector offer direct shortcuts. If clipboard permission is unavailable, **Select all** provides a manual copy fallback.\n\n**Add OPF** accepts a document, one slide, a slide array, a JSON value, or a single fenced JSON/OPF block. Paste into its text box, choose a `.opf`/`.json` file, drop a file on the editor, or load a public JSON URL. Preview first, then insert after the current slide, open a presentation, or replace selected content. Imports are validated and create one undo step. Normal copy/paste inside text fields remains native. Outside text fields, Cmd/Ctrl+V opens import review; Cmd/Ctrl+Shift+C opens Copy OPF; Cmd/Ctrl+O opens file import.\n\n**Browse galleries** includes 854 examples generated from the sibling PPTX.gallery checkout and a separate live PPTX.gallery registry. Search by name, category, or description. Select an entry to preview, copy its OPF, or insert it. **Manage galleries** adds/removes custom registry URLs; custom sources persist in this browser's local storage. Host defaults are defined in `opf-editor/examples/galleries.json`. The bundled snapshot is regenerated by `pnpm demo:editor`; it does not update in the background. Some presets are minimal definition examples rather than completed presentation slides.\n\nPublic PPTX.gallery detail links for layouts, colors, typography, themes, charts, backgrounds, narratives, blocks, and image treatments can be entered in the URL tab. Other sites should expose a direct OPF document or a registry JSON endpoint. Cross-origin servers must enable CORS. Requests omit credentials and referrers, are cancelable, and cap responses at 20 MB. A registry item's URL must stay on the configured origin; explicitly load another origin's URL when intended. The editor does not scrape arbitrary HTML pages or automatically load external fonts/catalog sources.\n\nA custom registry can mix inline OPF and relative document URLs:\n\n```json\n{\n \"name\": \"Team slides\",\n \"items\": [\n { \"id\": \"intro\", \"name\": \"Introduction\", \"category\": \"Team\", \"opf\": { \"slides\": [{ \"title\": \"Hello\" }] } },\n { \"id\": \"metrics\", \"name\": \"Metrics\", \"opfUrl\": \"./metrics.opf.json\" }\n ]\n}\n```\n\nImported documents should contain their required inline catalog records and assets. Inserting namespaces catalog IDs and conflicting asset/slide IDs, preserves the source slides' main design defaults, and leaves existing slides intact. It does not merge presentation-level speakers, organizations, or narrative metadata into the current deck. Open as a presentation to retain the complete source document. Conflicting or unresolved external catalog sources require a self-contained document before insertion.\n\nThe reusable npm APIs are browser-safe and independent of the demo UI:\n\n```js\nimport { parseOpfTransfer, serializeOpfTransfer, prepareOpfImport } from '@openpresentation/opf-editor/transfer';\nimport { loadOpfGallery, loadOpfGalleryItem } from '@openpresentation/opf-editor/galleries';\n\nconst markdown = serializeOpfTransfer(editor.document, {\n scope: 'slide', slideIndex: 0, format: 'markdown',\n});\nconst parsed = parseOpfTransfer(markdown);\nconst result = prepareOpfImport(editor.document, parsed, {\n mode: 'insert', slideIndex: 0,\n});\n// Host previews result.document before applying this single undoable change.\neditor.applyPatch([{ op: 'replace', path: '', value: result.document }], {\n source: 'import', rejectInvalid: true,\n});\n\nconst gallery = await loadOpfGallery('https://example.com/registry.json');\nconst document = await loadOpfGalleryItem(gallery.items[0], { gallery: gallery.url });\n```\n\nBoth gallery functions accept an `AbortSignal` and an injected `fetch` for host integrations and tests. Import/copy tests cover format round trips, invalid inputs, conflicting IDs and references, source isolation, and one-step undo; the generated 854-example snapshot is checked through insertion and SVG rendering.\n\n## All OPF properties\n\n**All properties** opens the schema-driven workspace beside a live SVG preview. Use Presentation, Current slide, Selection, or Design to navigate; add optional fields, select structured value forms, edit arrays/maps, and Apply a validated change with one undo step. Click content in the preview to locate its field. Nonvisual metadata remains part of the OPF document. A dirty draft must be applied or discarded before closing.\n\nThe `/schema` and `/schema-inspector` npm exports provide the reusable model and DOM inspector. `createSchemaInspector(container, {editor, path, onDraft})` exposes `navigate`, `commit`, `reset`, `destroy`, and read-only `document`/`dirty` getters. Use `onDraft` to render valid previews. The companion gallery `/spec` reference indexes the same 604 property definitions, and `/editor` embeds the shared browser build.\n\nSee [spec coverage](plans/spec-editor-coverage.md) for the distinction between complete field discovery and the remaining WYSIWYG rendering work.\n\n## Create, duplicate and delete content\n\nUse **Add content** in the editor toolbar, or the canvas button in Arrange mode. Choose a content kind, destination and insertion position. Starter content covers text, lists, charts, tables, metrics, quotes, code, timelines and groups. Image insertion accepts a local PNG/JPEG/WebP file or a source; local files are embedded as data URLs. Video insertion stores a source, but playback and native video export remain separate work. Source URLs and asset references still need the host's supported asset-resolution behavior.\n\nArrange handles also offer **Duplicate**, **Delete**, **Add after** and, for groups, **Add inside**. Duplication copies the complete content subtree while retaining asset references. Deletion prunes empty ancestor groups or their named region, preserving the slide and its metadata. Removing the last root block leaves a valid empty slide. Every operation preflights the complete candidate with the shared renderer and commits one undo step; stale forms are dismissed and strict overflow fails before mutation.\n\nAdding to implicit root content or a named-region leaf converts existing payloads into explicit blocks in the renderer's canonical field order. Headings, notes, design, metadata and neighboring regions stay intact. Named regions are kept in their existing positions; choose one as the destination. Existing composition weights remain attached to positions, so insertion/deletion can change which content occupies a weighted slot.\n\n```js\nimport {\n prepareBlockInsert, prepareBlockDuplicate, prepareBlockRemove, createContentBlock,\n listBlockContainers,\n} from '@openpresentation/opf-editor/layout';\nconst containers = listBlockContainers(editor.document, {includeImplicit: true});\nconst prepared = prepareBlockInsert(editor.document, containers[0].path,\n createContentBlock('table')); // omit index to append\n// Render prepared.document with your intended fonts before applying.\neditor.applyPatch(prepared.patches, {rejectInvalid: true});\n// Duplicate/remove take complete paths such as /slides/0/blocks/1.\n// canvas.openInsertMenu(containerPath?, index?) opens the browser palette.\n```\n\nThe headless helpers return `{document, patches, path, changed}` and include expected-value guards. Preserve those guards when applying patches. They need no browser, AI provider, account or hosted service. These helpers are published in editor 0.8.0; use the coordinated versions above and check installed exports when working with older packages. `/create-tests.html` and its installed-package equivalent exercise creation, image bytes, regions, duplication, deletion, strict-fit rejection, keyboard focus and undo.\n"
205
+ "markdown": "# Browser preview and live editing\n\nPublished editor 0.14.2 provides an embeddable SVG canvas in `@openpresentation/opf-editor/canvas`. OPF JSON remains the document; the canvas writes validated JSON Patch operations through an `EditorSession`. Draft edits render with the same SVG engine used for standalone previews. Completed edits produce one undoable change.\n\nThe published canvas covers the interactions below; complete PowerPoint feature coverage remains separate work. \u201CPixel perfect\u201D is a fidelity target with specific prerequisites and remaining gaps described below.\n\n## Install the published packages\n\nUse Node 24 with core 0.14.0, renderer 0.14.0, editor 0.14.2 and PPTX 0.14.0:\n\n```sh\nnpm install --save-exact @openpresentation/opf@0.14.0 @openpresentation/opf-render@0.14.0 @openpresentation/opf-editor@0.14.2 @openpresentation/opf-pptx@0.14.0\n```\n\nNo paid service or provider account is required. The six agent skills install with `npx @openpresentation/cli@0.11.0 skills install`. See the [quickstart](quickstart.md) for an installed-package workflow and the [compatibility matrix](compatibility-matrix.md) for separately scoped browser and native evidence.\n\nFor library development, separately regenerate unpublished local preview tarballs from sibling checkouts:\n\n```sh\npnpm build\nnode scripts/link-ecosystem.mjs\npnpm pack:ecosystem\npnpm test:packed-ecosystem\n```\n\nThe packed consumer installs actual tarballs without workspace aliases, exercises editing/SVG/PPTX, checks TypeScript declarations, and bundles a browser entry without Node shims. For a public release, advance source versions and downstream minimums/lockfiles together and follow the release process.\n\nThe gallery host example also offers local PPTX file import with preview/diagnostics and editable PowerPoint download. It commits active canvas text before export, shares preview text measurements and applies imports as a single undoable change. Save OPF to preserve the original source; native PowerPoint positions, fonts and unsupported features can change during conversion. The browser E2E checks run offline after loading and inspect the downloaded native merged table, then reimport and undo/redo. Native edit/save/reopen is a separate targeted check, not a pixel-equivalence claim.\n\n`pnpm prepare:gallery:registry` builds host controls from the immutable `exampleRefs.opf-editor` in `release-plan.json` while resolving libraries only from the fresh npm consumer. Package `verificationRefs` continue to point at actual published releases. The gallery manifest records both the example source hashes and registry package integrities. Updating example controls does not imply a new editor library release.\n\nScript fonts: when the pinned editor example loads faces from `./script-fonts/` and the pinned renderer has the script pack (0.10.0 and later), the registry build also writes `script-fonts.json` and lists its hash in `manifest.json`. The manifest is the reviewable half: every `@expo-google-fonts/noto-*` package, exact version, SPDX license, license-file hash and each face's SHA-256, taken from the published renderer. The faces are binaries (63 files, 66.9 MiB), so they are never committed to the gallery repository. The gallery build copies them from its own pinned npm dependencies into the untracked `public/opf-editor/script-fonts/` directory, verifying every hash, and writes the license notices beside them; nothing is fetched from a font CDN. See `scripts/gallery-script-fonts.mjs` and the gallery's `scripts/prepare-editor-script-fonts.mjs`.\n\nBase fonts (FF-41): when the pinned editor example loads `base-fonts.json` (opf-editor 0.10.5: the example passes the faces as the renderer's `extraLazyFonts`, renderer 0.11.7 and later; 0.10.4's example used its own `examples/base-font-gate.js`), the registry build starts the editor with Roboto Regular alone in `fonts.json` (217 KB instead of 12.8 MB) and writes every other eager face (Roboto in six more styles, Roboto Mono, the Office substitutes) as a separate file named after its hash beside it, listed with its SHA-256 in `base-fonts.json` and in `manifest.json`; the editor fetches only the faces a document draws, verified, through its font gate (`scripts/gallery-base-fonts.mjs`). An older pinned example keeps every eager face in `fonts.json`. A default Roboto deck loads about 0.7 MB of fonts instead of 12.8 MB.\n\nLazy fonts: when the pinned editor example calls `ensureLazyFonts` and the pinned renderer vendors faces (Intos for the default Aptos scheme and the open families, renderer 0.11.0 and later), the registry build also writes `lazy-fonts.json` and lists its hash in `manifest.json`. It pins every vendored package (exact version, SPDX license, license-file and notice hashes) and each face SHA-256, taken from the published renderer. The faces are binaries, so they are not committed either: the gallery build copies them from its pinned `@openpresentation/opf-render` package (`fonts/<name>/`) into the untracked `public/opf-editor/fonts/` directory, verifying every hash, and the editor fetches only the families a document uses, same-origin. See `scripts/gallery-lazy-fonts.mjs` and the gallery's `scripts/prepare-editor-lazy-fonts.mjs`.\n\nThe browser bundle links `playground.js.LEGAL.txt`, included in the hashed resources. It contains bundled license notices and package license files, including the vendored PptxGenJS MIT license. For dependencies that publish only an explicit MIT declaration in their README, the build retains that declaration/attribution and the standard terms; omitted upstream notices use a version-specific source URL and verified supplement hash. License collection runs offline from the verified installation and committed supplement. Runtime JavaScript is not rewritten to normalize comment whitespace.\n\n## Embed in any browser application\n\nMount after the host DOM exists. The container controls width; the slide retains its aspect ratio. React and Svelte applications can mount this framework-independent API in their normal client lifecycle and destroy it on unmount.\n\n```js\nimport { createCanvasEditor } from '@openpresentation/opf-editor/canvas';\nimport { loadFonts } from '@openpresentation/opf-render/fonts-browser';\n\n// Copy these licensed font files into your application's static assets first.\n// Use pinned, static faces; include every weight/style required by your deck.\nconst fonts = await loadFonts({ faces: [\n { url: '/fonts/Roboto-Regular.ttf', family: 'Roboto', weight: 400 },\n { url: '/fonts/Roboto-Bold.ttf', family: 'Roboto', weight: 700 },\n { url: '/fonts/RobotoMono-Regular.ttf', family: 'Roboto Mono', weight: 400 },\n] });\n\nconst canvas = createCanvasEditor(document.querySelector('#slide'), {\n presentation: {\n design: { theme: 'classic', fontScheme: 'roboto' },\n slides: [{ title: 'An editable presentation', text: 'Click to edit.' }],\n },\n fonts,\n onCommit: ({ editor }) => {\n const updatedOPF = editor.presentation; // Host owns saving and collaboration.\n console.log(updatedOPF);\n },\n onError: error => console.error(error.message),\n});\nawait canvas.ready;\n\n// JSON or LLM patches also update the slide automatically.\ncanvas.editor.set('slides.0.title', 'Changes from another control');\ncanvas.editor.undo();\n\n// On unmount:\n// canvas.destroy();\n// fonts.dispose();\n```\n\n`loadFonts` accepts explicit font-file URLs or `Uint8Array` data (`faces`). It uses the same bytes for Fontkit measurement and browser `FontFace` registration, awaits loading, reports failures, and exposes `dispose()` for its owned font faces; the handle's `pending` and `ensure` load the script and vendored faces a document needs, and the canvas waits for them before it renders. Cross-origin font URLs need CORS access. Load fonts once and share the handle between canvases. The canvas does not fetch fonts or catalog sources itself.\n\nFor standalone SVG export, pass the handle as `{ fonts }` to `renderSlideSvg` (one slide) or `renderSvg` (every slide); the export carries the font bytes and supplied license metadata. In a running browser canvas the registered fonts are already available, so embedding those bytes into every draft is unnecessary.\n\n```js\nimport { renderSlideSvg } from '@openpresentation/opf-render/svg';\nconst svg = renderSlideSvg(canvas.editor.presentation, 0, { fonts });\n```\n\nThe explicit `/svg` entry is browser safe. Browser-aware bundlers also select it for the renderer's root import. The Node root entry additionally supplies `svgToPng` and `svgToPdf`; those functions are not browser APIs.\n\n## Editing behavior\n\n| Content or action | Current behavior |\n| --- | --- |\n| Titles, subtitles, plain text, simple numeric values | One click enters editing with the caret at the clicked character (editor 0.10.2); press-drag selects a range; while editing, double-click selects a word and triple-click a paragraph. Focus a target and press Enter, Space or F2 to edit with all text selected. See *Text entry gestures* below. |\n| Table headers and string/number cells | Inline editing; numeric cells keep their numeric type. |\n| Lists, charts, metrics, quotes, code, timelines, rich text payloads | Select the object and edit its existing scalar fields in a floating form; valid drafts render immediately. A timeline event's `status` (`done`, `current`, `planned`) is a menu, not free text. |\n| Images | Edit source/alt fields; replace with a local PNG/JPEG/GIF/WebP file up to 20 MB. External sources still require a host image resolver. |\n| Collections | Add or remove the last item, subject to OPF schema validation. Empty structured collections may need authoring through source. |\n| Dynamic layout | Text edits recompose the slide through shared geometry; row/column/grid controls remain in the demo inspector. |\n| Undo and cancellation | Blur or Ctrl/Cmd+Enter commits plain text; Escape cancels; property forms have Apply/Cancel. |\n| Changes elsewhere | Unrelated edits are preserved; a changed selected payload cancels the stale local draft instead of overwriting it. This is conflict protection, not a distributed collaboration protocol. |\n| JSON editing | The demo Source view previews valid JSON beside the source; Apply records the document replacement. Invalid drafts retain the last valid preview. |\n\n`createCanvasEditor` accepts an existing `editor` session or a `presentation`, plus `slideIndex`, `fonts` (the renderer's fonts handle), `renderOptions`, `textEntry` (`'click'` by default, or `'dblclick'`), an optional empty `propertiesContainer` to dock forms outside the slide, and callbacks `onSelect`, `onDraft`, `onCommit`, `onCancel`, `onRender`, and `onError`. The returned object exposes `editor`, `ready`, `select`, `beginEdit`, `editProperties`, `commit`, `cancel`, `setSlide`, `setRenderOptions`, `setLayoutEditing`, `render`, and `destroy`. `commit()` and setters return false if a draft cannot be committed. Avoid using public `render(presentation)` as a second source of truth; normal document changes should flow through the session.\n\n## Text entry gestures\n\nEditor 0.10.2 follows the PowerPoint and Google Slides convention. Hover outlines a text target. A single press (mouse, pen, or a touch tap) on editable text selects the box, starts inline editing and puts the caret at the nearest character boundary to the pointer, including in wrapped, multi-line, centered, right-aligned, right-to-left and CJK text. Press and drag selects the range from the press point to the release point and never moves the box. While editing, a native double-click selects a word, a triple-click a line or paragraph, and a click elsewhere moves the caret. Clicking a different text target commits the current edit (an invalid edit still refuses) and enters the new target in the same click. Rich text uses the same gestures through its own pointer mapping.\n\nKeyboard entry keeps the replace convention: focus a target and press Enter, Space or F2 to edit with **all** text selected; `canvas.beginEdit(path)` does the same. Escape leaves editing and keeps the box selected. Images, video, charts and other non-text targets are unchanged: a click selects and a double-click opens their properties. Layout handles and block controls keep their own pointer handling.\n\n`createCanvasEditor(container, { textEntry: 'dblclick' })` keeps the older two-step gesture (a click selects, a double-click enters), but the double-click now places the caret at the pointer instead of selecting everything. Tests and hosts that used `dblclick()` and then relied on all text being selected should enter with the keyboard (focus the target, press Enter) or select explicitly; on the default canvas `dblclick()` now places the caret and selects the word under it. Carets are resolved from the traced SVG glyphs (each rendered line carries its source range) and converted to offsets in the input value, so CRLF sources, tabs and wrapped whitespace map exactly; real operating-system IME and bidi caret behavior are not verified.\n\n## Fidelity contract and remaining work\n\nThe same document, renderer version, dimensions, font bytes, and measurement provider produce the same SVG geometry in read and edit modes. Inline editing retains the actual SVG glyphs beneath a transparent native input; the input supplies the caret and selection. Browser regression checks compare draft text positions to standalone SVG rendering.\n\nThat is not a promise of identical raster pixels across browser engines, operating systems, or PowerPoint. Native caret/selection wrapping can differ from shaped SVG text, especially for mixed scripts, rich text, or unusual font features. Browser anti-aliasing and native PowerPoint typography also differ. Without a measurement provider the renderer uses deterministic estimates, which are not sufficient for a high-fidelity claim.\n\nStill needed for the requested complete editor:\n\n1. Continuous mixed-style typing and calibrated caret positioning, bidi/IME/vertical-script coverage. Rich text selection, formatting, links, and selected-text replacement are available through the [SVG formatting toolbar and range API](rich-text.md).\n2. More placement constraints and specialized interactions for fixed promoted regions and individual object geometry. **Add content** and **Arrange** already support the insertion, duplication and deletion described below, track resizing, sibling block dragging, and moving complete blocks between existing groups or slides.\n3. Full visual implementations for specialized charts, media playback, image crops/effects, theme chrome, and every catalog preset. Generic property editing does not imply complete renderer support.\n4. Approved screenshot baselines across representative fonts/layouts/browsers, vertical metric tests, and native PPTX comparison/embedding work.\n5. Broader font-family/script coverage and independently loadable font packs; the current base and Office substitute packs do not cover every requested font. Published packages, documentation examples and installed-package browser CI already exist.\n\nGoogle Fonts supports browser loading through its CSS API, and its repository permits self-hosting subject to each font's license. The OPF fidelity path uses pinned files for reproducibility instead of depending on whichever variant a hosted stylesheet returns. Keep the font's accompanying license. Sources: [Google Fonts CSS API](https://developers.google.com/fonts/docs/css2), [Google Fonts files and licenses](https://github.com/google/fonts/blob/main/README.md).\n\nThe [font roadmap](plans/font-roadmap.md) covers the starter Office substitutes and remaining families.\n\n## Verification\n\n`pnpm demo:editor` builds the playground and `/canvas-tests.html`. The browser harness exercises real font registration, live drafts, text-position parity, one-step undo, cancellation, external edit conflicts, number validation, table cells, structured payloads, collection changes, and cleanup. Node tests cover escaped field paths, typed values, immutable drafts, font loader failures and aborts. The renderer's 126-deck corpus and the coordinated CI's pinned furniture PNG baseline are separate checks. Current installed-package and browser results are recorded in the [compatibility matrix](compatibility-matrix.md); neither those checks nor historical rasters establish general native Office parity.\n\n## Copy, paste, files, and galleries\n\nThe demo's **Copy OPF** dialog exports the whole presentation, the current slide with its design/catalogs/assets, or the selected JSON value. Choose readable JSON, compact JSON, or a Markdown code block for an LLM. The slide toolbar and selection inspector offer direct shortcuts. If clipboard permission is unavailable, **Select all** provides a manual copy fallback.\n\n**Add OPF** accepts a document, one slide, a slide array, a JSON value, or a single fenced JSON/OPF block. Paste into its text box, choose a `.opf`/`.json` file, drop a file on the editor, or load a public JSON URL. Preview first, then insert after the current slide, open a presentation, or replace selected content. Imports are validated and create one undo step. Normal copy/paste inside text fields remains native. Outside text fields, Cmd/Ctrl+V opens import review; Cmd/Ctrl+Shift+C opens Copy OPF; Cmd/Ctrl+O opens file import.\n\n**Browse galleries** includes 854 examples generated from the sibling PPTX.gallery checkout and a separate live PPTX.gallery registry. Search by name, category, or description. Select an entry to preview, copy its OPF, or insert it. **Manage galleries** adds/removes custom registry URLs; custom sources persist in this browser's local storage. Host defaults are defined in `opf-editor/examples/galleries.json`. The bundled snapshot is regenerated by `pnpm demo:editor`; it does not update in the background. Some presets are minimal definition examples rather than completed presentation slides.\n\nPublic PPTX.gallery detail links for layouts, colors, typography, themes, charts, backgrounds, narratives, blocks, and image treatments can be entered in the URL tab. Other sites should expose a direct OPF document or a registry JSON endpoint. Cross-origin servers must enable CORS. Requests omit credentials and referrers, are cancelable, and cap responses at 20 MB. A registry item's URL must stay on the configured origin; explicitly load another origin's URL when intended. The editor does not scrape arbitrary HTML pages or automatically load external fonts/catalog sources.\n\nA custom registry can mix inline OPF and relative document URLs:\n\n```json\n{\n \"name\": \"Team slides\",\n \"items\": [\n { \"id\": \"intro\", \"name\": \"Introduction\", \"category\": \"Team\", \"opf\": { \"slides\": [{ \"title\": \"Hello\" }] } },\n { \"id\": \"metrics\", \"name\": \"Metrics\", \"opfUrl\": \"./metrics.opf.json\" }\n ]\n}\n```\n\nImported documents should embed the catalog records and assets they use. Inserting slides copies their records with core `copySlides`: groups match by `source`, an identical record is reused, a differing `custom` record is renamed `<id>-2` and a differing catalog revision moves into `custom` as `<id>-2`, so the inserted slides look the same and the existing slides stay intact. It does not merge presentation-level speakers, organizations, or narrative metadata into the current deck. Open as a presentation to retain the complete source document.\n\nThe reusable npm APIs are browser-safe and independent of the demo UI:\n\n```js\nimport { parseOpfTransfer, serializeOpfTransfer, prepareOpfImport } from '@openpresentation/opf-editor/transfer';\nimport { loadOpfGallery, loadOpfGalleryItem } from '@openpresentation/opf-editor/galleries';\n\nconst markdown = serializeOpfTransfer(editor.presentation, {\n scope: 'slide', slideIndex: 0, format: 'markdown',\n});\nconst parsed = parseOpfTransfer(markdown);\nconst result = prepareOpfImport(editor.presentation, parsed, {\n mode: 'insert', slideIndex: 0,\n});\n// Host previews result.presentation before applying this single undoable change.\neditor.applyPatch([{ op: 'replace', path: '', value: result.presentation }], {\n source: 'import', rejectInvalid: true,\n});\n\nconst gallery = await loadOpfGallery('https://example.com/registry.json');\nconst presentation = await loadOpfGalleryItem(gallery.items[0], { gallery: gallery.url });\n```\n\nBoth gallery functions accept an `AbortSignal` and an injected `fetch` for host integrations and tests. Import/copy tests cover format round trips, invalid inputs, conflicting IDs and references, source isolation, and one-step undo; the generated 854-example snapshot is checked through insertion and SVG rendering.\n\n## All OPF properties\n\n**All properties** opens the schema-driven workspace beside a live SVG preview. Use Presentation, Current slide, Selection, or Design to navigate; add optional fields, select structured value forms, edit arrays/maps, and Apply a validated change with one undo step. Click content in the preview to locate its field. Nonvisual metadata remains part of the OPF document. A dirty draft must be applied or discarded before closing.\n\nThe `/schema` and `/schema-inspector` npm exports provide the reusable model and DOM inspector. `createSchemaInspector(container, {editor, path, onDraft})` exposes `navigate`, `commit`, `reset`, `destroy`, and read-only `presentation`/`dirty` getters. Use `onDraft` to render valid previews. The companion gallery `/spec` reference indexes the same 604 property definitions, and `/editor` embeds the shared browser build.\n\nSee [spec coverage](plans/spec-editor-coverage.md) for the distinction between complete field discovery and the remaining WYSIWYG rendering work.\n\n## Create, duplicate and delete content\n\nUse **Add content** in the editor toolbar, or the canvas button in Arrange mode. Choose a content kind, destination and insertion position. Starter content covers text, lists, charts, tables, metrics, quotes, code, timelines and groups. Image insertion accepts a local PNG/JPEG/WebP file or a source; local files are embedded as data URLs. Video insertion stores a source, but playback and native video export remain separate work. Source URLs and asset references still need the host's supported asset-resolution behavior.\n\nArrange handles also offer **Duplicate**, **Delete**, **Add after** and, for groups, **Add inside**. Duplication copies the complete content subtree while retaining asset references. Deletion prunes empty ancestor groups or their named region, preserving the slide and its metadata. Removing the last root block leaves a valid empty slide. Every operation preflights the complete candidate with the shared renderer and commits one undo step; stale forms are dismissed and strict overflow fails before mutation.\n\nAdding to implicit root content or a named-region leaf converts existing payloads into explicit blocks in the renderer's canonical field order. Headings, notes, design, metadata and neighboring regions stay intact. Named regions are kept in their existing positions; choose one as the destination. Existing composition weights remain attached to positions, so insertion/deletion can change which content occupies a weighted slot.\n\n```js\nimport {\n prepareBlockInsert, prepareBlockDuplicate, prepareBlockRemove, createContentBlock,\n listBlockContainers,\n} from '@openpresentation/opf-editor/layout';\nconst containers = listBlockContainers(editor.presentation, {includeImplicit: true});\nconst prepared = prepareBlockInsert(editor.presentation, containers[0].path,\n createContentBlock('table')); // omit index to append\n// Render prepared.presentation with your intended fonts before applying.\neditor.applyPatch(prepared.patches, {rejectInvalid: true});\n// Duplicate/remove take complete paths such as /slides/0/blocks/1.\n// canvas.openInsertMenu(containerPath?, index?) opens the browser palette.\n```\n\nThe headless helpers return `{document, patches, path, changed}` and include expected-value guards. Preserve those guards when applying patches. They need no browser, AI provider, account or hosted service. These helpers are published in editor 0.8.0; use the coordinated versions above and check installed exports when working with older packages. `/create-tests.html` and its installed-package equivalent exercise creation, image bytes, regions, duplication, deletion, strict-fit rejection, keyboard focus and undo.\n"
206
206
  },
207
207
  {
208
208
  "slug": "llm-authoring",
209
209
  "file": "docs/llm-authoring.md",
210
210
  "title": "Authoring OPF with an LLM",
211
- "markdown": '# Authoring OPF with an LLM\n\nWrite a complete JSON document with `name` and `slides`. Put visible words in slide fields, not presentation metadata. Use `*.opf.json` filenames and stable, unique slide `id` values when a deck will be revised repeatedly.\n\n```json\n{\n "name": "Launch decision",\n "slides": [{\n "id": "recommendation",\n "title": "Launch to the pilot group first",\n "composition": { "mode": "row", "weights": [2, 1] },\n "blocks": [\n { "items": ["Validate onboarding", "Measure activation", "Fix the largest drop-off"] },\n { "metric": { "value": 200, "label": "Pilot customers" } }\n ],\n "notes": "Confirm the rollout owner and checkpoint date."\n }]\n}\n```\n\nChoose one content structure per slide:\n\n- Root payloads for a simple slide: `text`, `items`, `image`, `chart`, `table`, `code`, `metric`, `quote`, or `timeline`. Mark a roadmap\'s progress with `status` on the timeline events (`done`, `current`, `planned`); the engines draw it, so do not add color or schedule-health words to fake it.\n- `blocks` for a sequence that should reflow. Set `composition` only when an arrangement matters. Omit it to let the engine choose.\n- Promoted regions such as `left`, `center+right`, `top`, and `bottom` for spatially meaningful content. Regions must not overlap. Do not mix regions with root payloads or blocks.\n\nUse catalog IDs from the installed package or supply inline records in `catalogs`. A gallery route is a stable identifier, but an extended gallery layout may need the inline record included in the copied document. Do not invent an unresolvable layout or assume a network lookup will happen.\n\nA testimonial quote is `{ "quote": { "text": "...", "attribution": "Priya Raman", "role": "VP Operations, Acme", "photo": { "src": "asset:priya", "alt": "Priya Raman" } } }`: the attribution holds the name, `role` the title and organization, and `photo` a circular headshot that needs alt text.\n\nPick `bullets` for plain talking points and `items` for a list whose entries have the same shape or a supporting `description` (see [content payloads](content-payloads.md#items-versus-bullets)). Mark an inline code span with `{ "text": "pnpm install", "code": true }`, a stretch in another language with `lang`, lines of a code block with `code.highlight` (`[3, [5, 7]]`), and stamp a deck with `design.watermark` `{ "text": "DRAFT", "opacity": 0.1 }`.\n\nA headline or quote may carry inline formatting: `title`, `subtitle`, `tag` and `quote.text` accept a string or `TextRun[]`, for example `"title": ["Revenue grew ", { "text": "28%", "color": "accent1" }]` to color the word that carries the claim, or a `cite` marker on the claim. Use a plain string unless a run needs its own style.\n\nTables use `{ "columns": ["Category", "Value"], "rows": [["A", 10]] }`. Charts put a `type` and the same tabular structure inside `chart.data`, plus an `alt` sentence that says what the data shows. For an amount beside a rate, a `combo` chart draws columns with line series (`line`, default the last series) and can put a line on a secondary axis (`secondaryAxis`); see [chart-options.md](chart-options.md#combo-charts). Images use a source string or `{ "src": "...", "alt": "..." }`; use the top-level `assets` registry and `asset:<id>` references for reuse. The local renderer does not fetch remote sources.\n\n## Deck facts: built-in variables\n\nDo not retype the speaker, organization or deck name into slide text. Put them in the root `speaker`, `organization`, `name`, `description` and `author` once and reference them: `{{speaker.name}}`, `{{speaker.title}}`, `{{organization.name}}`, `{{organization.tagline}}`, `{{deck.name}}` inside any string, and `var:speaker.photo` or `var:organization.logo` as a whole image field. `{{speakers}}` joins every speaker name, and `speaker.<id>.<field>` / `organization.<id>.<field>` address one entry by id. Nothing about a speaker is drawn unless you reference it (or set the `speaker` header/footer field); the organization\'s tagline, legal name, domain, email and phone are likewise only available this way. A built-in whose source field is missing resolves to nothing and warns; an unknown path is a validation error. Speaker ids and organization ids must each be unique and `Speaker.organizationId` must name an organization. See [templates and variables](templates-and-variables.md#built-in-variables).\n\n## Revision loop\n\n1. Validate with `validate` (or `opf validate`). Fix error findings at their returned JSON paths, then read the warnings: unknown catalog IDs, missing alt text, low contrast, overflow and placeholder text each have a rule id and a suggested fix ([validate](validate.md)).\n2. Render with `onDiagnostic` and inspect `text-overflow` / `small-cell` paths. Shorten text, reduce the number of blocks, change composition, or explicitly split the slide. Revalidate after edits.\n3. Use `composition.overflow: "error"` for a strict text-layout gate. It does not certify chart readability, font availability, or exact PowerPoint rendering.\n4. Inspect the actual preview and exported PPTX. Geometry is shared; font substitution and specialized objects can still differ. Previews draw an open look-alike where the license-restricted font cannot be bundled (metric-compatible where one exists, for example Carlito for Calibri; Intos for Aptos), but the exported PPTX keeps the font name the user selected.\n5. Apply focused JSON Patch edits through the editor session and retain undo history. Resolve stable slide IDs to current array indices before constructing patches; indices can change when slides are inserted or moved.\n\nPreserve factual content, sources, notes, and asset descriptions during layout repair. A fit diagnostic is a request to revise the slide; it is not permission to silently drop the end of a paragraph.\n\nSee [dynamic composition](dynamic-composition.md), [content payloads](content-payloads.md), and [design precedence](design-resolution.md).\n\nUse nested `blocks` to keep related content together. Put `composition` on the group to arrange its children, for example a column of evidence inside a row of sections. Read `composeSlide().groups` for group bounds and `items[].path` for precise leaf edits. Groups inherit readability constraints; splitting content into more levels does not make text smaller.\n\nUse `paginate(deck)` when a draft exceeds readable space. Review the returned ordinary OPF slides and source mappings before export. Pagination preserves source text exactly; it does not summarize or rewrite it. An atomic item that cannot fit produces a diagnostic for a targeted edit.\n'
211
+ "markdown": '# Authoring OPF with an LLM\n\nWrite a complete JSON document with `name` and `slides`. Put visible words in slide fields, not presentation metadata. Use `*.opf.json` filenames and stable, unique slide `id` values when a deck will be revised repeatedly.\n\n```json\n{\n "name": "Launch decision",\n "slides": [{\n "id": "recommendation",\n "title": "Launch to the pilot group first",\n "composition": { "mode": "row", "weights": [2, 1] },\n "blocks": [\n { "items": ["Validate onboarding", "Measure activation", "Fix the largest drop-off"] },\n { "metric": { "value": 200, "label": "Pilot customers" } }\n ],\n "notes": "Confirm the rollout owner and checkpoint date."\n }]\n}\n```\n\nChoose one content structure per slide:\n\n- Root payloads for a simple slide: `text`, `items`, `image`, `chart`, `table`, `code`, `metric`, `quote`, or `timeline`. Mark a roadmap\'s progress with `status` on the timeline events (`done`, `current`, `planned`); the engines draw it, so do not add color or schedule-health words to fake it.\n- `blocks` for a sequence that should reflow. Set `composition` only when an arrangement matters. Omit it to let the engine choose.\n- Promoted regions such as `left`, `center+right`, `top`, and `bottom` for spatially meaningful content. Regions must not overlap. Do not mix regions with root payloads or blocks.\n\nReference records by id from the default catalog (`@openpresentation/opf/catalog`) or a catalog the host registers (`name:id` for a named group), and put the deck\'s own records in `catalogs.custom`. Embed what a deck uses before saving it (`embed`, `opf embed`), so it renders the same with no catalog registered. Do not invent an unresolvable layout or assume a network lookup will happen.\n\nA testimonial quote is `{ "quote": { "text": "...", "attribution": "Priya Raman", "role": "VP Operations, Acme", "photo": { "src": "asset:priya", "alt": "Priya Raman" } } }`: the attribution holds the name, `role` the title and organization, and `photo` a circular headshot that needs alt text.\n\nPick `bullets` for plain talking points and `items` for a list whose entries have the same shape or a supporting `description` (see [content payloads](content-payloads.md#items-versus-bullets)). Mark an inline code span with `{ "text": "pnpm install", "code": true }`, a stretch in another language with `lang`, lines of a code block with `code.highlight` (`[3, [5, 7]]`), and stamp a deck with `design.watermark` `{ "text": "DRAFT", "opacity": 0.1 }`.\n\nA headline or quote may carry inline formatting: `title`, `subtitle`, `tag` and `quote.text` accept a string or `TextRun[]`, for example `"title": ["Revenue grew ", { "text": "28%", "color": "accent1" }]` to color the word that carries the claim, or a `cite` marker on the claim. Use a plain string unless a run needs its own style.\n\nTables use `{ "columns": ["Category", "Value"], "rows": [["A", 10]] }`. Charts put a `type` and the same tabular structure inside `chart.data`, plus an `alt` sentence that says what the data shows. For an amount beside a rate, a `combo` chart draws columns with line series (`line`, default the last series) and can put a line on a secondary axis (`secondaryAxis`); see [chart-options.md](chart-options.md#combo-charts). Images use a source string or `{ "src": "...", "alt": "..." }`; use the top-level `assets` registry and `asset:<id>` references for reuse. The local renderer does not fetch remote sources.\n\n## Deck facts: built-in variables\n\nDo not retype the speaker, organization or deck name into slide text. Put them in the root `speaker`, `organization`, `name`, `description` and `author` once and reference them: `{{speaker.name}}`, `{{speaker.title}}`, `{{organization.name}}`, `{{organization.tagline}}`, `{{deck.name}}` inside any string, and `var:speaker.photo` or `var:organization.logo` as a whole image field. `{{speakers}}` joins every speaker name, and `speaker.<id>.<field>` / `organization.<id>.<field>` address one entry by id. Nothing about a speaker is drawn unless you reference it (or set the `speaker` header/footer field); the organization\'s tagline, legal name, domain, email and phone are likewise only available this way. A built-in whose source field is missing resolves to nothing and warns; an unknown path is a validation error. Speaker ids and organization ids must each be unique and `Speaker.organizationId` must name an organization. See [templates and variables](templates-and-variables.md#built-in-variables).\n\n## Revision loop\n\n1. Validate with `validate` (or `opf validate`). Fix error findings at their returned JSON paths, then read the warnings: references that resolve nowhere (`opf/unresolved-reference`), missing alt text, low contrast, overflow and placeholder text each have a rule id and a suggested fix ([validate](validate.md)).\n2. Render with `onDiagnostic` and inspect `text-overflow` / `small-cell` paths. Shorten text, reduce the number of blocks, change composition, or explicitly split the slide. Revalidate after edits.\n3. Use `composition.overflow: "error"` for a strict text-layout gate. It does not certify chart readability, font availability, or exact PowerPoint rendering.\n4. Inspect the actual preview and exported PPTX. Geometry is shared; font substitution and specialized objects can still differ. Previews draw an open look-alike where the license-restricted font cannot be bundled (metric-compatible where one exists, for example Carlito for Calibri; Intos for Aptos), but the exported PPTX keeps the font name the user selected.\n5. Apply focused JSON Patch edits through the editor session and retain undo history. Resolve stable slide IDs to current array indices before constructing patches; indices can change when slides are inserted or moved.\n\nPreserve factual content, sources, notes, and asset descriptions during layout repair. A fit diagnostic is a request to revise the slide; it is not permission to silently drop the end of a paragraph.\n\nSee [dynamic composition](dynamic-composition.md), [content payloads](content-payloads.md), and [design precedence](design-resolution.md).\n\nUse nested `blocks` to keep related content together. Put `composition` on the group to arrange its children, for example a column of evidence inside a row of sections. Read `composeSlide().groups` for group bounds and `items[].path` for precise leaf edits. Groups inherit readability constraints; splitting content into more levels does not make text smaller.\n\nUse `paginate(deck)` when a draft exceeds readable space. Review the returned ordinary OPF slides and source mappings before export. Pagination preserves source text exactly; it does not summarize or rewrite it. An atomic item that cannot fit produces a diagnostic for a targeted edit.\n'
212
212
  },
213
213
  {
214
214
  "slug": "markdown",
@@ -262,25 +262,25 @@ var docsData = Object.freeze([
262
262
  "slug": "quickstart",
263
263
  "file": "docs/quickstart.md",
264
264
  "title": "Developer quickstart",
265
- "markdown": "# Developer quickstart\n\nA new developer can install the **published** OPF packages into a fresh Node 24\nproject and author, validate, compose, paginate, edit with undo, preview and export\na representative deck. No account, model call, or sibling repository checkout\nis required.\n\nThis is a documented supported subset, not universal Office or all-feature\nparity. See the [compatibility matrix](compatibility-matrix.md) for what is\nshipped versus deferred.\n\n## Versions\n\nPin the coordinated set from `release-plan.json` (currently core **0.13.0**,\nCLI **0.11.0**, renderer **0.13.1**, PPTX **0.13.2**, editor **0.12.1**). All of these packages declare\n`engines.node: >=22`; Node 24 is the toolchain this quickstart is verified on.\n\n```sh\nnode -v # 24.x (verified); 22 and later are declared\nnpm install @openpresentation/opf@0.13.0 \\\n @openpresentation/opf-render@0.13.1 \\\n @openpresentation/opf-editor@0.12.1 \\\n @openpresentation/opf-pptx@0.13.2 \\\n @openpresentation/cli@0.11.0\n```\n\nCopy [`docs/quickstart/developer-quickstart.opf.json`](quickstart/developer-quickstart.opf.json)\ninto that project as `deck.opf.json`. That file is a docs fixture, not one of\nthe 126 decks in `@openpresentation/opf/examples`. Verify the install came from the registry\n(`package-lock.json` `resolved` URLs start with `https://registry.npmjs.org/`)\nand that you did not add `file:` dependencies on this repository.\n\nThe [format card](format-card.md) describes ColorRef, named variables and the\ncurrent source contract. `opf bundle` can inline resolved catalog records for\nportable offline authoring; it does not download remote assets. Keep the\nColorRef docs fixture outside the 126-deck example/golden corpus in this update.\n\n## Author and validate\n\n```sh\nnpx --no-install opf --version\nnpx --no-install opf validate deck.opf.json\nnpx --no-install opf validate deck.opf.json --format text\n```\n\nThe CLI bundles schema, catalogs and the checker. Validation never renders; `opf render`, `opf export` and\n`opf import` produce and read files through the optional peers `@openpresentation/opf-render` and\n`@openpresentation/opf-pptx` (see [the CLI reference](cli.md)).\n`opf --version` reports the CLI and bundled core. Successful validation is not\nvisual verification.\n\nLibrary equivalents:\n\n```js\nimport { readFile } from 'node:fs/promises';\nimport { validate } from '@openpresentation/opf';\n\nconst source = await readFile('deck.opf.json', 'utf8');\nconst document = JSON.parse(source);\nconsole.log(validate(document)); // a parsed document: findings of every category\nconsole.log(validate(source)); // JSON text: the same findings with line and column\n```\n\n## Offline fonts, composition, pagination\n\n```js\nimport { paginate, resolveSlideContext } from '@openpresentation/opf';\nimport { composeSlide } from '@openpresentation/opf/composition';\nimport { prepareNodeFonts } from '@openpresentation/opf-render/fonts-node';\n\nconst { options: fonts } = await prepareNodeFonts({ pack: 'base' });\nconst { options } = resolveSlideContext(document, 0, { fonts });\nconst geometry = composeSlide(document.slides[0], options);\nconst { presentation, pages } = paginate(document, { fonts });\n```\n\n`prepareNodeFonts({ pack: 'base' })` loads the bundled Roboto faces for\n`design.fontScheme: 'roboto'`. Its `options` is the fonts handle: deck-level\nverbs such as `paginate` take it as `{ fonts }` and read its\n`textMeasurement`. `resolveSlideContext(document, index, { fonts })` resolves\none slide's layout, canvas, theme and font families (slide design, then deck\ndesign, then theme, then the default font scheme) into the options `composeSlide`\ntakes, so you never look up a font scheme yourself; an unknown font-scheme id\ncomes back as an `unresolved-font-scheme` diagnostic. `paginate`\ndoes the same for every slide. The helper does not install system fonts or\nchange the authored scheme. Reuse the same `fonts` for SVG preview and PPTX\nexport.\n\nShared headers and footers use `furniture-flow-v2`. Body content stays between\n`geometry.furniture.headerBottom` and `geometry.furniture.footerTop`.\n\nPagination returns a new presentation plus source mappings. It preserves\nauthored text, whitespace and reading order; it does not drop overflowed\ncontent.\n\n```sh\nnpx --no-install opf paginate deck.opf.json paginated.opf.json\n```\n\n## Edit with undo\n\n```js\nimport { createEditorSession } from '@openpresentation/opf-editor';\n\nconst editor = createEditorSession(document, { rejectInvalid: true });\nconst original = editor.document.slides[0].title;\neditor.set('slides.0.title', 'Edited title');\neditor.undo();\n// original title, including whitespace, is restored\n```\n\nThe CLI can apply JSON Patch edits (`opf edit`) but has no persistent undo\nhistory. Use the editor session or version control for undo.\n\n## Preview and export\n\n```js\nimport { renderSvgDeck, svgToPng, svgToPdf } from '@openpresentation/opf-render';\nimport { toPptx } from '@openpresentation/opf-pptx';\n\nconst svgs = renderSvgDeck(presentation, options);\nconst png = await svgToPng(svgs[0], options);\nconst pdf = await svgToPdf(svgs, options);\nconst pptx = await toPptx(presentation, options);\n```\n\n`renderSvg` / `renderSvgDeck` are the local preview. PNG rasterizes that SVG.\nPDF (opf-render 0.12.0 and later) converts the same SVG to **vector paths with\nselectable, searchable text** in embedded font subsets, with no second layout pass;\npass `{ mode: 'raster' }` for the image-per-slide output that renderers up to 0.11.9\nalways wrote. Supply the same font files as for PNG (`fontFiles`); vector PDF never\nuses system fonts.\n`toPptx` is the supported editable PowerPoint export from OPF. Shared\nheaders/footers in that file are tagged slide shapes (`OPF_FURNITURE_V1`), not\nnative Office Header/Footer objects (`p:hf` / notes master). Opening the file\nin Microsoft PowerPoint, compiling furniture into real Header/Footer objects,\nand round-tripping native fidelity is\n[issue 87](https://github.com/OpenPresentation/opf/issues/87), not this\nquickstart.\n\nBrowser preview uses the same SVG core plus\n`@openpresentation/opf-render/fonts-browser` and\n`@openpresentation/opf-editor/canvas`. Load the same font bytes the Node helper\nresolved. Do not fetch fonts from the network at render time.\n\n## Prove it\n\nFrom this repository, after a normal `pnpm install`:\n\n```sh\nnode scripts/test-developer-quickstart.mjs\n```\n\nThat script creates an empty temp project, installs the published versions from\nthe npm registry, copies this example, and asserts validate, offline\nfonts, furniture composition, pagination, undo, SVG, PNG, PDF and PPTX.\nIt fails if any package is a `file:` or workspace link.\n\n## What this does not cover\n\n- Renderer native-width residuals:\n [opf-render#24](https://github.com/OpenPresentation/opf-render/issues/24)\n- Native PowerPoint open/edit/save/reopen and real Office Header/Footer (`p:hf`):\n [opf#87](https://github.com/OpenPresentation/opf/issues/87)\n- Remaining GitHub [issue 88](https://github.com/OpenPresentation/opf/issues/88)\n checklist (the Inspector overlay/json-options, gallery Playground+Editor\n links, and Header & footer playground example are already live on\n production; the issue stays open)\n- Archived font-shaping prototypes (not in the published runtime)\n- PDF/UA or PDF/A conformance, general SVG diagrams, and Mermaid\n"
265
+ "markdown": "# Developer quickstart\n\nA new developer can install the **published** OPF packages into a fresh Node 24\nproject and author, validate, compose, paginate, edit with undo, preview and export\na representative deck. No account, model call, or sibling repository checkout\nis required.\n\nThis is a documented supported subset, not universal Office or all-feature\nparity. See the [compatibility matrix](compatibility-matrix.md) for what is\nshipped versus deferred.\n\n## Versions\n\nPin the coordinated set from `release-plan.json` (currently core **0.14.0**,\nCLI **0.11.0**, renderer **0.14.0**, PPTX **0.14.0**, editor **0.14.2**). All of these packages declare\n`engines.node: >=22`; Node 24 is the toolchain this quickstart is verified on.\n\n```sh\nnode -v # 24.x (verified); 22 and later are declared\nnpm install @openpresentation/opf@0.14.0 \\\n @openpresentation/opf-render@0.14.0 \\\n @openpresentation/opf-editor@0.14.2 \\\n @openpresentation/opf-pptx@0.14.0 \\\n @openpresentation/cli@0.11.0\n```\n\nCopy [`docs/quickstart/developer-quickstart.opf.json`](quickstart/developer-quickstart.opf.json)\ninto that project as `deck.opf.json`. That file is a docs fixture, not one of\nthe 126 decks in `@openpresentation/opf/examples`. Verify the install came from the registry\n(`package-lock.json` `resolved` URLs start with `https://registry.npmjs.org/`)\nand that you did not add `file:` dependencies on this repository.\n\nThe [format card](format-card.md) describes ColorRef, named variables and the\ncurrent source contract. `opf embed` embeds the catalog records a deck uses for\nportable offline authoring; it does not download remote assets. Keep the\nColorRef docs fixture outside the 126-deck example/golden corpus in this update.\n\n## Author and validate\n\n```sh\nnpx --no-install opf --version\nnpx --no-install opf validate deck.opf.json\nnpx --no-install opf validate deck.opf.json --format text\n```\n\nThe CLI bundles schema, catalogs and the checker. Validation never renders; `opf render`, `opf export` and\n`opf import` produce and read files through the optional peers `@openpresentation/opf-render` and\n`@openpresentation/opf-pptx` (see [the CLI reference](cli.md)).\n`opf --version` reports the CLI and bundled core. Successful validation is not\nvisual verification.\n\nLibrary equivalents:\n\n```js\nimport { readFile } from 'node:fs/promises';\nimport { validate } from '@openpresentation/opf';\n\nconst source = await readFile('deck.opf.json', 'utf8');\nconst document = JSON.parse(source);\nconsole.log(validate(document)); // a parsed document: findings of every category\nconsole.log(validate(source)); // JSON text: the same findings with line and column\n```\n\n## Offline fonts, composition, pagination\n\n```js\nimport { paginate, resolveSlideContext } from '@openpresentation/opf';\nimport { composeSlide } from '@openpresentation/opf/composition';\nimport { loadFonts } from '@openpresentation/opf-render/fonts-node';\n\nconst fonts = await loadFonts({ pack: 'base' });\nconst { options } = resolveSlideContext(document, 0, { fonts });\nconst geometry = composeSlide(document.slides[0], options);\nconst { presentation, pages } = paginate(document, { fonts });\n```\n\n`loadFonts({ pack: 'base' })` loads the bundled Roboto faces for\n`design.fontScheme: 'roboto'`. It returns the fonts handle: every deck-level\nverb (`paginate`, `renderSvg`, `svgToPng`, `svgToPdf`, `toPptx`) takes it as\n`{ fonts }` and reads what it needs from it (the `textMeasurement`, the faces\nto embed, the font files). `resolveSlideContext(document, index, { fonts, catalogs })` resolves\none slide's layout, canvas, theme and font families (slide design, then deck\ndesign, then theme, then the engine default font scheme) into the options `composeSlide`\ntakes, so you never look up a font scheme yourself; a reference that resolves\nnowhere comes back as an `unresolved-reference` diagnostic. `paginate`\ndoes the same for every slide. The helper does not install system fonts or\nchange the authored scheme. Reuse the same `fonts` for SVG preview and PPTX\nexport.\n\nShared headers and footers use `furniture-flow-v2`. Body content stays between\n`geometry.furniture.headerBottom` and `geometry.furniture.footerTop`.\n\nPagination returns a new presentation plus source mappings. It preserves\nauthored text, whitespace and reading order; it does not drop overflowed\ncontent.\n\n```sh\nnpx --no-install opf paginate deck.opf.json paginated.opf.json\n```\n\n## Edit with undo\n\n```js\nimport { createEditorSession } from '@openpresentation/opf-editor';\n\nconst editor = createEditorSession(document, { rejectInvalid: true });\nconst original = editor.presentation.slides[0].title;\neditor.set('slides.0.title', 'Edited title');\neditor.undo();\n// original title, including whitespace, is restored\n```\n\nThe CLI can apply JSON Patch edits (`opf edit`) but has no persistent undo\nhistory. Use the editor session or version control for undo.\n\n## Preview and export\n\n```js\nimport { renderSvg, svgToPng, svgToPdf } from '@openpresentation/opf-render';\nimport { toPptx } from '@openpresentation/opf-pptx';\n\nconst svgs = renderSvg(presentation, { fonts }); // one SVG per slide\nconst png = await svgToPng(svgs[0], { fonts });\nconst pdf = await svgToPdf(svgs, { fonts });\nconst pptx = await toPptx(presentation, { fonts });\n```\n\n`renderSvg` is the local preview of every slide (`renderSlideSvg` draws one). PNG rasterizes that SVG.\nPDF (opf-render 0.12.0 and later) converts the same SVG to **vector paths with\nselectable, searchable text** in embedded font subsets, with no second layout pass;\npass `{ mode: 'raster' }` for the image-per-slide output that renderers up to 0.11.9\nalways wrote. Pass the same fonts handle as for PNG; vector PDF never uses system\nfonts.\n`toPptx` is the supported editable PowerPoint export from OPF. Shared\nheaders/footers in that file are tagged slide shapes (`OPF_FURNITURE_V1`), not\nnative Office Header/Footer objects (`p:hf` / notes master). Opening the file\nin Microsoft PowerPoint, compiling furniture into real Header/Footer objects,\nand round-tripping native fidelity is\n[issue 87](https://github.com/OpenPresentation/opf/issues/87), not this\nquickstart.\n\nBrowser preview uses the same SVG core plus\n`@openpresentation/opf-render/fonts-browser` and\n`@openpresentation/opf-editor/canvas`. Load the same font bytes the Node helper\nresolved. Do not fetch fonts from the network at render time.\n\n## Prove it\n\nFrom this repository, after a normal `pnpm install`:\n\n```sh\nnode scripts/test-developer-quickstart.mjs\n```\n\nThat script creates an empty temp project, installs the published versions from\nthe npm registry, copies this example, and asserts validate, offline\nfonts, furniture composition, pagination, undo, SVG, PNG, PDF and PPTX.\nIt fails if any package is a `file:` or workspace link.\n\n## What this does not cover\n\n- Renderer native-width residuals:\n [opf-render#24](https://github.com/OpenPresentation/opf-render/issues/24)\n- Native PowerPoint open/edit/save/reopen and real Office Header/Footer (`p:hf`):\n [opf#87](https://github.com/OpenPresentation/opf/issues/87)\n- Remaining GitHub [issue 88](https://github.com/OpenPresentation/opf/issues/88)\n checklist (the Inspector overlay/json-options, gallery Playground+Editor\n links, and Header & footer playground example are already live on\n production; the issue stays open)\n- Archived font-shaping prototypes (not in the published runtime)\n- PDF/UA or PDF/A conformance, general SVG diagrams, and Mermaid\n"
266
266
  },
267
267
  {
268
268
  "slug": "release-process",
269
269
  "file": "docs/release-process.md",
270
270
  "title": "OPF Release Process",
271
- "markdown": "# OPF Release Process\n\nUse Node 24 (`24.x`) for all future source, candidate and registry verification.\nThe packages declare the open-ended `engines.node` `>=22` (RR-20, tested on Node 22, 24 and 26 by the `node-range`\nCI jobs, which run in the merge queue and on pull requests labelled `full-ci`); a closed range such as `24.x` makes npm on any other Node silently install an older release, and\n`pnpm check:engines-range` fails on one, including in a lockfile that pins a release made after the fix.\nThe next releases must document the [Node 24 migration](migrations/node24.md)\nand use new versions. Historical dual-runtime release records remain unchanged.\n\nThis document is the release runbook for the public JavaScript package,\n[`@openpresentation/opf`](https://www.npmjs.com/package/@openpresentation/opf).\n\nThe canonical release path is:\n\n1. Merge the release commit to `main`.\n2. Push a semver tag whose name matches the package version.\n3. Let GitHub Actions publish to npm through npm trusted publishing.\n4. Verify npm and the automatically generated GitHub release notes.\n\n## Agent authorization and coordinated release order\n\nThe owner authorized agents to prepare and publish npm releases on 2026-09-29\nand for future releases whenever a release is required. This does not waive any\ngate: a release still needs a merged release-prep PR, green required checks and\nthe verification below. Agents keep publishing on the trusted-publishing\nworkflows (GitHub Actions OIDC with `--provenance`); a local `npm publish` is a\nfallback only when a workflow cannot run, and it loses the provenance\nattestation that every previous version carries.\n\nThe engine packages depend on each other, so publish in this order and wait for\neach version to appear on the registry before starting the next:\n\n1. `@openpresentation/opf` (this repository, `opf-vX.Y.Z` tag).\n2. `@openpresentation/opf-render` (`opf-render-vX.Y.Z` tag) and\n `@openpresentation/opf-pptx` (`opf-pptx-vX.Y.Z` tag). Both depend on core; PPTX\n also devDepends on the renderer, so publish the renderer first.\n3. `@openpresentation/opf-editor` (`opf-editor-vX.Y.Z` tag), which depends on core\n and peers/devDepends on the renderer and PPTX.\n\nEach sibling's release-prep PR raises its dependency floors to the just-published\nversions. Its lockfile can only be refreshed after the upstream version exists on\nnpm (`npm install --package-lock-only`), so merge sibling release PRs only after\nthe upstream publish. `@openpresentation/cli` bundles core and is released\nseparately by `cli-publish.yml` (`cli-vX.Y.Z`) when a fresh bundle is needed.\n\nA sibling pull request that needs a core that is not on npm yet (a breaking core release and its sibling PRs, `Depends-On: OpenPresentation/opf#N`) declares it in its `package.json` as `\"opf\": { \"requiresUnreleasedCore\": \"X.Y.Z\" }` (RR-55). Its CI then skips only the packed install against published dependencies (`npm run test:packed`, with a `::notice::`) while the installed published `@openpresentation/opf` is lower than `X.Y.Z`; every linked-ecosystem step still runs. The field is for pull requests only: `release-train.mjs prep` deletes it (and refuses a field above the core of the train), and `plan` flags a release commit whose `package.json` still carries it.\n\nRelease-prep PRs contain only version bumps, changelog entries, dependency ranges\nand lockfile changes (plus current-instruction docs). The changelog entries are\nnot written by hand: every change adds a fragment `changes/<slug>.md` in its own\nPR (RR-46, [changes/README.md](../changes/README.md)), and the release-prep PR\nruns the assembler, which moves the fragments into the new release section of\n`CHANGELOG.md` and deletes them. Each sibling repository has the same\n`changes/` directory and script:\n\n```sh\n# core (packages are named in each fragment: opf = CHANGELOG.md, cli = packages/cli/CHANGELOG.md)\nnode scripts/changelog-fragments.mjs assemble --version X.Y.Z --package opf --date YYYY-MM-DD\nnode scripts/changelog-fragments.mjs assemble --version A.B.C --package cli # only when the CLI is released\n# opf-render, opf-pptx, opf-editor\nnode scripts/changelog-fragments.mjs assemble --version X.Y.Z --date YYYY-MM-DD [--summary \"Patch release: ...\"]\n```\n\nUse `--dry-run` to preview, and check that no `changes/*.md` other than\n`README.md` remains for the released package before opening the PR. After the whole set is on\nthe registry, a follow-up docs change updates `release-plan.json`, the\ncompatibility matrix and the quickstart to the published set, and the gallery\nconsumer dependencies are bumped.\n\n## Release train (scripted, RR-51)\n\n`scripts/release-train.mjs` runs the coordinated release above in lockstep order. It is run by the supervisor with\ntheir own `gh` login (or `GH_TOKEN`); every command is a dry run unless `--execute` is given. It only reads, opens\nrelease-prep pull requests and creates tags: it never merges a pull request and never publishes. Each package is still\npublished by its own repository's trusted-publishing workflow (OIDC, `--provenance`) when its tag appears.\n\nName the versions of the train, any subset: `--core X.Y.Z --render X.Y.Z --pptx X.Y.Z --editor X.Y.Z --cli X.Y.Z`.\nThe order is always core, then opf-render, then opf-pptx, then opf-editor and the CLI.\n\n```sh\n# 1. What is missing (read only; exit 1 until every package is on npm)\nnode scripts/release-train.mjs plan --core 0.12.1 --render 0.12.1 --pptx 0.12.3 --editor 0.11.3 --cli 0.10.1\n\n# 2. The release-prep PR of the next package, once its upstream is on npm (dry run first: a scratch clone and the diff)\nnode scripts/release-train.mjs prep render --core 0.12.1 --render 0.12.1 [--item RR-nn]\nnode scripts/release-train.mjs prep render --core 0.12.1 --render 0.12.1 --item RR-nn --execute\n\n# 3. After a person merged it with green CI: tag the release commit, wait for the publish run and npm, verify\nnode scripts/release-train.mjs tag render --core 0.12.1 --render 0.12.1 --execute\n\n# 4. Verify any published version (also run by `tag` and `run`)\nnode scripts/release-train.mjs verify @openpresentation/opf-render@0.12.1\n\n# Or the whole sequence: it stops at the first step that needs a person and prints the command to resume\nnode scripts/release-train.mjs run --core 0.12.1 --render 0.12.1 --pptx 0.12.3 --editor 0.11.3 --cli 0.10.1 --execute\n```\n\nWhat each step checks:\n\n- `plan` reads, per package: whether the version is already on npm (then it is only verified), the version in the\n manifest at `main`'s head, the release commit (the commit that set the version) and its merged release-prep PR, the\n required checks on the release commit (the `main` ruleset's required checks; for a repository without a ruleset,\n every reported check), the tag, whether the upstream versions of the train are on npm, and the dependency floors.\n It flags every sibling whose `@openpresentation/opf` floor stays below a new core in the train, with hints from core's\n fragments or changelog section: the tool cannot know whether a core release moves geometry, so the release owner\n decides whether the lockstep rule below applies (flag, never decided).\n- `prep` refuses until every upstream version of the train is on npm. In a scratch clone of `main` it bumps the\n version, runs `node scripts/changelog-fragments.mjs assemble --version X.Y.Z --date <today>` (with `--package opf` or\n `--package cli` in core, `--summary` when given), raises the floors that name a package of the train (caret and\n tilde ranges keep their operator; exact devDependency pins move to the exact version; `workspace:*` is untouched;\n for the CLI, `PEER_RANGES` in `packages/cli/src/peers.ts` follows its peer ranges) and refreshes the lockfile\n (`npm install --package-lock-only` in the siblings, `pnpm install --lockfile-only` in core). It fails if a fragment\n for the package is left or if anything other than the manifest, changelog, fragments, lockfile and peers file\n changed. The PR body lists README lines that name the previous version for a person to review; prose is not\n rewritten. It also deletes `opf.requiresUnreleasedCore` from the manifest when the sibling declares it (see above; `plan` flags a release commit that keeps it). Branch `codex/release-<package>-<x-y-z>` unless `--branch` is given. An open release-prep PR (found by\n branch or by a \"release <package> X.Y.Z\" title) or a merged one is detected and nothing is written.\n- `tag` re-verifies right before tagging: the version at the release commit, that the commit is on `main`, green\n required checks, every upstream of the train on npm and every runtime floor naming a version npm has. It then creates\n `refs/tags/<prefix>X.Y.Z` (`opf-v`, `opf-render-v`, `opf-pptx-v`, `opf-editor-v`, `cli-v`) with\n `POST /repos/{repo}/git/refs` on the release commit (checks that are still running, or not reported yet right after\n the merge, stop it unless `--checks-wait-minutes N` is given: it then polls them every `--poll-seconds` for up to N\n minutes, and a red check still stops it at once), polls that repository's publish workflow run for the tag\n (default every 120 s, up to 90 min), polls npm until the version is visible, and runs `verify`. A tag that already\n exists on the release commit is not created again; one on another commit stops the train. A failed publish run stops\n with its link: never move or re-push the tag; re-run a transient failure (`gh run rerun <id> --failed`), fix a real\n one on `main` with a new version.\n- `verify` checks the registry manifest, that `gitHead` equals the tagged commit (and that the commit is on `main` and\n carries the version), `dist.attestations` with the SLSA v1 provenance predicate, the provenance statement itself\n (built by `.github/workflows/<publish workflow>` of the package's repository on `refs/tags/<tag>` from the tagged\n commit, subject digest equal to `dist.integrity`), `npm audit signatures --include-attestations` in a scratch\n project that installs exactly that version (no invalid or missing signatures; the package has a verified\n attestation), and the GitHub release where the repository's workflow creates one (core only; the sibling and CLI\n workflows create none). The dist-tag is reported for information.\n - Propagation: right after a publish, npm serves the packument (with `dist.attestations.url`) minutes before the\n attestation bundle (HTTP 404 `{\"error\":\"Not found\"}`) and before a fresh `npm install` can resolve the version\n (`notarget`, \"No matching version found\"); the first live use, core 0.12.1, failed `verify` on exactly these two\n for 2-4 minutes. Only those two checks wait: `tag` and `run` retry the bundle fetch and the scratch install every\n 30 s (`--attest-poll-seconds`) for up to 15 min (`--attest-wait-minutes`; 0 turns the wait off) and say what they\n wait for in the log. Any answer that is not \"not yet there\" fails at once without waiting: a bundle that is\n present but has no SLSA provenance or names another repository, workflow, ref, commit or digest, an invalid or\n missing signature, any other install error. If the wait runs out, the check fails with \"still not propagated\n after N min\". Standalone `verify` keeps failing fast; `verify <package>@X.Y.Z --wait <minutes>` gives it the same\n retry (for example right after a publish run you started by hand).\n- `run` repeats `plan` per package in order and does the next step: verify what is on npm, stop at an open release-prep\n PR, open a missing one (`prep`), wait for pending checks on a merged release commit, then `tag`. Re-running it after\n a stop skips every step already done; a version already on npm is never published again.\n\nThe follow-up docs PR (`release-plan.json`, the compatibility matrix and the quickstart) and the gallery consumer bumps\nstay by hand after the train.\n\n`.github/workflows/release-train.yml` is the same script as a `workflow_dispatch` (inputs: the versions and `mode`).\nUntil the GitHub App of [opf#298](https://github.com/OpenPresentation/opf/issues/298) exists it is plan-only: tags\npushed with a workflow's `GITHUB_TOKEN` start no other workflow (so the publish workflows would never run), and\n`GITHUB_TOKEN` cannot push branches or open pull requests in the sibling repositories, so `mode: execute` fails at\nonce. With the App (`ECOSYSTEM_APP_ID` variable and `ECOSYSTEM_APP_PRIVATE_KEY` secret, the roller's App) `mode:\nexecute` runs `run --execute` with the App's token.\n\nThe steps by hand below remain the fallback when the script cannot run.\n\n## Geometry-moving core releases: lockstep floors\n\nCore composition changes that move geometry (for example opf#169 cover centering) make the preview and the PPTX export drift when `@openpresentation/opf-render` and `@openpresentation/opf-pptx` resolve different core versions (measured: 186-300 pt title offsets on covers).\n\nRule: when a core release contains composition or geometry changes, the same release train must raise BOTH the renderer's and PPTX's core floor (`dependencies` and, where present, `peerDependencies`) to that core version, publish them together, and raise the editor's floor too. Do not release core alone and leave a sibling on the older floor.\n\nThe parity harness must always run with `--import <opf>/scripts/register-local-opf.mjs` (as `run.ps1` does) so every engine shares one core.\n\n## Release Preconditions\n\nBefore tagging, confirm that the release commit on `main` already contains:\n\n- `packages/javascript/package.json` with the intended version.\n- `CHANGELOG.md` with the matching release section (assembled from `changes/`, see above).\n- Passing `OPF CI` on the release commit.\n\nThe publish workflow validates the tag name against\n`packages/javascript/package.json`, so the tag must point at the release commit.\n\n## Tag And Publish\n\nUse the `opf-vX.Y.Z` tag form for the package release:\n\n```sh\ngit checkout main\ngit pull origin main\ngrep '\"version\"' packages/javascript/package.json\ngit tag opf-vX.Y.Z\ngit push origin opf-vX.Y.Z\n```\n\nFor example, version `0.3.0` used:\n\n```sh\ngit tag opf-v0.3.0\ngit push origin opf-v0.3.0\n```\n\nPushing the tag triggers `.github/workflows/npm-publish.yml`. The workflow:\n\n- runs on tags matching `opf-v*` or `@openpresentation/opf@v*`\n- installs dependencies with pnpm on Node 24\n- verifies the tag matches `packages/javascript/package.json`\n- runs typecheck and tests\n- runs the npm package dry-run check\n- publishes from `packages/javascript` with `npm publish --access public --provenance`\n\nDo not rerun a successful publish for the same version. npm package versions are\nimmutable; a second publish for an already-published version should fail.\n\n## Trusted Publishing\n\nnpm publishing is configured to use GitHub Actions OIDC trusted publishing, not\na long-lived npm token.\n\nExpected npm package trusted-publisher settings:\n\n| Setting | Value |\n|---|---|\n| Package | `@openpresentation/opf` |\n| Publisher | GitHub Actions |\n| Organization/repository | `OpenPresentation/opf` |\n| Workflow filename | `npm-publish.yml` |\n| Environment | empty, unless the workflow is later moved behind a GitHub Environment |\n| Permission | `npm publish` |\n\nExpected workflow settings:\n\n```yaml\npermissions:\n contents: read\n id-token: write\n```\n\nThe publish step should not set `NODE_AUTH_TOKEN`:\n\n```yaml\n- name: Publish to npm\n working-directory: packages/javascript\n run: npm publish --access public\n```\n\nIf a future release fails with npm authentication errors, check the npm\ntrusted-publisher settings first. Only use an `NPM_TOKEN` repository secret as a\ntemporary fallback, and remove or revoke it once OIDC publishing works again.\n\n## Verify The Release\n\n`node scripts/release-train.mjs verify <package>@X.Y.Z` runs every registry check of this section and the provenance\nchecks (see \"Release train\" above). By hand, after the workflow completes, verify npm:\n\n```sh\nnpm view @openpresentation/opf version\n```\n\nThe output should equal the package version that was tagged.\n\nSpot-check the validator API from a clean project or temporary directory:\n\n```sh\nnpm install @openpresentation/opf@X.Y.Z\nnode --input-type=module -e \"import {validate} from '@openpresentation/opf'; console.log(validate({name:'t', narrative:'not-a-real-id', slides:[{title:'t'}]}, {only:['format','references']}).findings)\"\n```\n\nThe expected result is one warning about an unknown narratives catalog id.\n\n## GitHub Release Notes\n\nThe core tag workflow creates a GitHub Release from the matching changelog section after publishing. Verify that release after npm is verified. If release creation failed, create the missing release for the existing tag:\n\n```sh\ngh release create opf-vX.Y.Z \\\n --repo OpenPresentation/opf \\\n --title '@openpresentation/opf X.Y.Z' \\\n --notes-file /path/to/release-notes.md\n```\n\nUse the matching `## X.Y.Z` section from `CHANGELOG.md` as the release notes.\n\n## Troubleshooting\n\nIf the tag/version check fails, the tag does not point at the release commit or\nthe tag name does not match `packages/javascript/package.json`. Delete the bad\nlocal and remote tag, fetch `main`, and tag the correct commit:\n\n```sh\ngit push origin :refs/tags/opf-vX.Y.Z\ngit tag -d opf-vX.Y.Z\ngit checkout main\ngit pull origin main\ngit tag opf-vX.Y.Z\ngit push origin opf-vX.Y.Z\n```\n\nIf tests fail, fix the code on `main`, create a new release commit, and move the\ntag only if npm has not already published that version.\n\nIf npm publish fails with `ENEEDAUTH`, confirm:\n\n- npm has a trusted publisher for `OpenPresentation/opf`\n- the trusted publisher uses workflow filename `npm-publish.yml`\n- `.github/workflows/npm-publish.yml` has `id-token: write`\n- the publish job is running on a modern Node/npm toolchain\n\nIf npm publish fails after the version is already present on npm, do not retry\nthe same publish. Verify the package and treat the failure as a duplicate\npublish attempt.\n"
271
+ "markdown": "# OPF Release Process\n\nUse Node 24 (`24.x`) for all future source, candidate and registry verification.\nThe packages declare the open-ended `engines.node` `>=22` (RR-20, tested on Node 22, 24 and 26 by the `node-range`\nCI jobs, which run in the merge queue and on pull requests labelled `full-ci`); a closed range such as `24.x` makes npm on any other Node silently install an older release, and\n`pnpm check:engines-range` fails on one, including in a lockfile that pins a release made after the fix.\nThe next releases must document the [Node 24 migration](migrations/node24.md)\nand use new versions. Historical dual-runtime release records remain unchanged.\n\nThis document is the release runbook for the public JavaScript package,\n[`@openpresentation/opf`](https://www.npmjs.com/package/@openpresentation/opf).\n\nThe canonical release path is:\n\n1. Merge the release commit to `main`.\n2. Push a semver tag whose name matches the package version.\n3. Let GitHub Actions publish to npm through npm trusted publishing.\n4. Verify npm and the automatically generated GitHub release notes.\n\n## Agent authorization and coordinated release order\n\nThe owner authorized agents to prepare and publish npm releases on 2026-09-29\nand for future releases whenever a release is required. This does not waive any\ngate: a release still needs a merged release-prep PR, green required checks and\nthe verification below. Agents keep publishing on the trusted-publishing\nworkflows (GitHub Actions OIDC with `--provenance`); a local `npm publish` is a\nfallback only when a workflow cannot run, and it loses the provenance\nattestation that every previous version carries.\n\nThe engine packages depend on each other, so publish in this order and wait for\neach version to appear on the registry before starting the next:\n\n1. `@openpresentation/opf` (this repository, `opf-vX.Y.Z` tag).\n2. `@openpresentation/opf-render` (`opf-render-vX.Y.Z` tag) and\n `@openpresentation/opf-pptx` (`opf-pptx-vX.Y.Z` tag). Both depend on core; PPTX\n also devDepends on the renderer, so publish the renderer first.\n3. `@openpresentation/opf-editor` (`opf-editor-vX.Y.Z` tag), which depends on core\n and peers/devDepends on the renderer and PPTX.\n\nEach sibling's release-prep PR raises its dependency floors to the just-published\nversions. Its lockfile can only be refreshed after the upstream version exists on\nnpm (`npm install --package-lock-only`), so merge sibling release PRs only after\nthe upstream publish. `@openpresentation/cli` bundles core and is released\nseparately by `cli-publish.yml` (`cli-vX.Y.Z`) when a fresh bundle is needed.\n\nA sibling pull request that needs a core that is not on npm yet (a breaking core release and its sibling PRs, `Depends-On: OpenPresentation/opf#N`) declares it in its `package.json` as `\"opf\": { \"requiresUnreleasedCore\": \"X.Y.Z\" }` (RR-55). Its CI then skips only the packed install against published dependencies (`npm run test:packed`, with a `::notice::`) while the installed published `@openpresentation/opf` is lower than `X.Y.Z`; every linked-ecosystem step still runs. The field is for pull requests only: `release-train.mjs prep` deletes it (and refuses a field above the core of the train), and `plan` flags a release commit whose `package.json` still carries it.\n\nRelease-prep PRs contain only version bumps, changelog entries, dependency ranges\nand lockfile changes (plus current-instruction docs). The changelog entries are\nnot written by hand: every change adds a fragment `changes/<slug>.md` in its own\nPR (RR-46, [changes/README.md](../changes/README.md)), and the release-prep PR\nruns the assembler, which moves the fragments into the new release section of\n`CHANGELOG.md` and deletes them. Each sibling repository has the same\n`changes/` directory and script:\n\n```sh\n# core (packages are named in each fragment: opf = CHANGELOG.md, cli = packages/cli/CHANGELOG.md)\nnode scripts/changelog-fragments.mjs assemble --version X.Y.Z --package opf --date YYYY-MM-DD\nnode scripts/changelog-fragments.mjs assemble --version A.B.C --package cli # only when the CLI is released\n# opf-render, opf-pptx, opf-editor\nnode scripts/changelog-fragments.mjs assemble --version X.Y.Z --date YYYY-MM-DD [--summary \"Patch release: ...\"]\n```\n\nUse `--dry-run` to preview, and check that no `changes/*.md` other than\n`README.md` remains for the released package before opening the PR. After the whole set is on\nthe registry, a follow-up docs change updates `release-plan.json`, the\ncompatibility matrix and the quickstart to the published set, and the gallery\nconsumer dependencies are bumped.\n\n## Release train (scripted, RR-51)\n\n`scripts/release-train.mjs` runs the coordinated release above in lockstep order. It is run by the supervisor with\ntheir own `gh` login (or `GH_TOKEN`); every command is a dry run unless `--execute` is given. It only reads, opens\nrelease-prep pull requests and creates tags: it never merges a pull request and never publishes. Each package is still\npublished by its own repository's trusted-publishing workflow (OIDC, `--provenance`) when its tag appears.\n\nName the versions of the train, any subset: `--core X.Y.Z --render X.Y.Z --pptx X.Y.Z --editor X.Y.Z --cli X.Y.Z`.\nThe order is always core, then opf-render, then opf-pptx, then opf-editor and the CLI.\n\n```sh\n# 1. What is missing (read only; exit 1 until every package is on npm)\nnode scripts/release-train.mjs plan --core 0.12.1 --render 0.12.1 --pptx 0.12.3 --editor 0.11.3 --cli 0.10.1\n\n# 2. The release-prep PR of the next package, once its upstream is on npm (dry run first: a scratch clone and the diff)\nnode scripts/release-train.mjs prep render --core 0.12.1 --render 0.12.1 [--item RR-nn]\nnode scripts/release-train.mjs prep render --core 0.12.1 --render 0.12.1 --item RR-nn --execute\n\n# 3. After a person merged it with green CI: tag the release commit, wait for the publish run and npm, verify\nnode scripts/release-train.mjs tag render --core 0.12.1 --render 0.12.1 --execute\n\n# 4. Verify any published version (also run by `tag` and `run`)\nnode scripts/release-train.mjs verify @openpresentation/opf-render@0.12.1\n\n# Or the whole sequence: it stops at the first step that needs a person and prints the command to resume\nnode scripts/release-train.mjs run --core 0.12.1 --render 0.12.1 --pptx 0.12.3 --editor 0.11.3 --cli 0.10.1 --execute\n```\n\nWhat each step checks:\n\n- `plan` reads, per package: whether the version is already on npm (then it is only verified), the version in the\n manifest at `main`'s head, the release commit (the commit that set the version) and its merged release-prep PR, the\n required checks on the release commit (the `main` ruleset's required checks; for a repository without a ruleset,\n every reported check), the tag, whether the upstream versions of the train are on npm, and the dependency floors.\n It flags every sibling whose `@openpresentation/opf` floor stays below a new core in the train, with hints from core's\n fragments or changelog section: the tool cannot know whether a core release moves geometry, so the release owner\n decides whether the lockstep rule below applies (flag, never decided).\n- `prep` refuses until every upstream version of the train is on npm. In a scratch clone of `main` it bumps the\n version, runs `node scripts/changelog-fragments.mjs assemble --version X.Y.Z --date <today>` (with `--package opf` or\n `--package cli` in core, `--summary` when given), raises the floors that name a package of the train (caret and\n tilde ranges keep their operator; exact devDependency pins move to the exact version; `workspace:*` is untouched;\n for the CLI, `PEER_RANGES` in `packages/cli/src/peers.ts` follows its peer ranges) and refreshes the lockfile\n (`npm install --package-lock-only` in the siblings, `pnpm install --lockfile-only` in core). It fails if a fragment\n for the package is left or if anything other than the manifest, changelog, fragments, lockfile and peers file\n changed. The PR body lists README lines that name the previous version for a person to review; prose is not\n rewritten. It also deletes `opf.requiresUnreleasedCore` from the manifest when the sibling declares it (see above; `plan` flags a release commit that keeps it). Branch `codex/release-<package>-<x-y-z>` unless `--branch` is given. An open release-prep PR (found by\n branch or by a \"release <package> X.Y.Z\" title) or a merged one is detected and nothing is written.\n- `tag` re-verifies right before tagging: the version at the release commit, that the commit is on `main`, green\n required checks, every upstream of the train on npm and every runtime floor naming a version npm has. It then creates\n `refs/tags/<prefix>X.Y.Z` (`opf-v`, `opf-render-v`, `opf-pptx-v`, `opf-editor-v`, `cli-v`) with\n `POST /repos/{repo}/git/refs` on the release commit (checks that are still running, or not reported yet right after\n the merge, stop it unless `--checks-wait-minutes N` is given: it then polls them every `--poll-seconds` for up to N\n minutes, and a red check still stops it at once), polls that repository's publish workflow run for the tag\n (default every 120 s, up to 90 min), polls npm until the version is visible, and runs `verify`. A tag that already\n exists on the release commit is not created again; one on another commit stops the train. A failed publish run stops\n with its link: never move or re-push the tag; re-run a transient failure (`gh run rerun <id> --failed`), fix a real\n one on `main` with a new version.\n- `verify` checks the registry manifest, that `gitHead` equals the tagged commit (and that the commit is on `main` and\n carries the version), `dist.attestations` with the SLSA v1 provenance predicate, the provenance statement itself\n (built by `.github/workflows/<publish workflow>` of the package's repository on `refs/tags/<tag>` from the tagged\n commit, subject digest equal to `dist.integrity`), `npm audit signatures --include-attestations` in a scratch\n project that installs exactly that version (no invalid or missing signatures; the package has a verified\n attestation), and the GitHub release where the repository's workflow creates one (core only; the sibling and CLI\n workflows create none). The dist-tag is reported for information.\n - Propagation: right after a publish, npm serves the packument (with `dist.attestations.url`) minutes before the\n attestation bundle (HTTP 404 `{\"error\":\"Not found\"}`) and before a fresh `npm install` can resolve the version\n (`notarget`, \"No matching version found\"); the first live use, core 0.12.1, failed `verify` on exactly these two\n for 2-4 minutes. Only those two checks wait: `tag` and `run` retry the bundle fetch and the scratch install every\n 30 s (`--attest-poll-seconds`) for up to 15 min (`--attest-wait-minutes`; 0 turns the wait off) and say what they\n wait for in the log. Any answer that is not \"not yet there\" fails at once without waiting: a bundle that is\n present but has no SLSA provenance or names another repository, workflow, ref, commit or digest, an invalid or\n missing signature, any other install error. If the wait runs out, the check fails with \"still not propagated\n after N min\". Standalone `verify` keeps failing fast; `verify <package>@X.Y.Z --wait <minutes>` gives it the same\n retry (for example right after a publish run you started by hand).\n- `run` repeats `plan` per package in order and does the next step: verify what is on npm, stop at an open release-prep\n PR, open a missing one (`prep`), wait for pending checks on a merged release commit, then `tag`. Re-running it after\n a stop skips every step already done; a version already on npm is never published again.\n\nThe follow-up docs PR (`release-plan.json`, the compatibility matrix and the quickstart) and the gallery consumer bumps\nstay by hand after the train.\n\n`.github/workflows/release-train.yml` is the same script as a `workflow_dispatch` (inputs: the versions and `mode`).\nUntil the GitHub App of [opf#298](https://github.com/OpenPresentation/opf/issues/298) exists it is plan-only: tags\npushed with a workflow's `GITHUB_TOKEN` start no other workflow (so the publish workflows would never run), and\n`GITHUB_TOKEN` cannot push branches or open pull requests in the sibling repositories, so `mode: execute` fails at\nonce. With the App (`ECOSYSTEM_APP_ID` variable and `ECOSYSTEM_APP_PRIVATE_KEY` secret, the roller's App) `mode:\nexecute` runs `run --execute` with the App's token.\n\nThe steps by hand below remain the fallback when the script cannot run.\n\n## Geometry-moving core releases: lockstep floors\n\nCore composition changes that move geometry (for example opf#169 cover centering) make the preview and the PPTX export drift when `@openpresentation/opf-render` and `@openpresentation/opf-pptx` resolve different core versions (measured: 186-300 pt title offsets on covers).\n\nRule: when a core release contains composition or geometry changes, the same release train must raise BOTH the renderer's and PPTX's core floor (`dependencies` and, where present, `peerDependencies`) to that core version, publish them together, and raise the editor's floor too. Do not release core alone and leave a sibling on the older floor.\n\nThe parity harness must always run with `--import <opf>/scripts/register-local-opf.mjs` (as `run.ps1` does) so every engine shares one core.\n\n## Release Preconditions\n\nBefore tagging, confirm that the release commit on `main` already contains:\n\n- `packages/javascript/package.json` with the intended version.\n- `CHANGELOG.md` with the matching release section (assembled from `changes/`, see above).\n- Passing `OPF CI` on the release commit.\n\nThe publish workflow validates the tag name against\n`packages/javascript/package.json`, so the tag must point at the release commit.\n\n## Tag And Publish\n\nUse the `opf-vX.Y.Z` tag form for the package release:\n\n```sh\ngit checkout main\ngit pull origin main\ngrep '\"version\"' packages/javascript/package.json\ngit tag opf-vX.Y.Z\ngit push origin opf-vX.Y.Z\n```\n\nFor example, version `0.3.0` used:\n\n```sh\ngit tag opf-v0.3.0\ngit push origin opf-v0.3.0\n```\n\nPushing the tag triggers `.github/workflows/npm-publish.yml`. The workflow:\n\n- runs on tags matching `opf-v*` or `@openpresentation/opf@v*`\n- installs dependencies with pnpm on Node 24\n- verifies the tag matches `packages/javascript/package.json`\n- runs typecheck and tests\n- runs the npm package dry-run check\n- publishes from `packages/javascript` with `npm publish --access public --provenance`\n\nDo not rerun a successful publish for the same version. npm package versions are\nimmutable; a second publish for an already-published version should fail.\n\n## Trusted Publishing\n\nnpm publishing is configured to use GitHub Actions OIDC trusted publishing, not\na long-lived npm token.\n\nExpected npm package trusted-publisher settings:\n\n| Setting | Value |\n|---|---|\n| Package | `@openpresentation/opf` |\n| Publisher | GitHub Actions |\n| Organization/repository | `OpenPresentation/opf` |\n| Workflow filename | `npm-publish.yml` |\n| Environment | empty, unless the workflow is later moved behind a GitHub Environment |\n| Permission | `npm publish` |\n\nExpected workflow settings:\n\n```yaml\npermissions:\n contents: read\n id-token: write\n```\n\nThe publish step should not set `NODE_AUTH_TOKEN`:\n\n```yaml\n- name: Publish to npm\n working-directory: packages/javascript\n run: npm publish --access public\n```\n\nIf a future release fails with npm authentication errors, check the npm\ntrusted-publisher settings first. Only use an `NPM_TOKEN` repository secret as a\ntemporary fallback, and remove or revoke it once OIDC publishing works again.\n\n## Verify The Release\n\n`node scripts/release-train.mjs verify <package>@X.Y.Z` runs every registry check of this section and the provenance\nchecks (see \"Release train\" above). By hand, after the workflow completes, verify npm:\n\n```sh\nnpm view @openpresentation/opf version\n```\n\nThe output should equal the package version that was tagged.\n\nSpot-check the validator API from a clean project or temporary directory:\n\n```sh\nnpm install @openpresentation/opf@X.Y.Z\nnode --input-type=module -e \"import {validate} from '@openpresentation/opf'; console.log(validate({name:'t', narrative:'not-a-real-id', slides:[{title:'t'}]}, {only:['format','references']}).findings)\"\n```\n\nThe expected result is one `opf/unresolved-reference` warning for the narrative (no catalog is registered).\n\n## GitHub Release Notes\n\nThe core tag workflow creates a GitHub Release from the matching changelog section after publishing. Verify that release after npm is verified. If release creation failed, create the missing release for the existing tag:\n\n```sh\ngh release create opf-vX.Y.Z \\\n --repo OpenPresentation/opf \\\n --title '@openpresentation/opf X.Y.Z' \\\n --notes-file /path/to/release-notes.md\n```\n\nUse the matching `## X.Y.Z` section from `CHANGELOG.md` as the release notes.\n\n## Troubleshooting\n\nIf the tag/version check fails, the tag does not point at the release commit or\nthe tag name does not match `packages/javascript/package.json`. Delete the bad\nlocal and remote tag, fetch `main`, and tag the correct commit:\n\n```sh\ngit push origin :refs/tags/opf-vX.Y.Z\ngit tag -d opf-vX.Y.Z\ngit checkout main\ngit pull origin main\ngit tag opf-vX.Y.Z\ngit push origin opf-vX.Y.Z\n```\n\nIf tests fail, fix the code on `main`, create a new release commit, and move the\ntag only if npm has not already published that version.\n\nIf npm publish fails with `ENEEDAUTH`, confirm:\n\n- npm has a trusted publisher for `OpenPresentation/opf`\n- the trusted publisher uses workflow filename `npm-publish.yml`\n- `.github/workflows/npm-publish.yml` has `id-token: write`\n- the publish job is running on a modern Node/npm toolchain\n\nIf npm publish fails after the version is already present on npm, do not retry\nthe same publish. Verify the package and treat the failure as a duplicate\npublish attempt.\n"
272
272
  },
273
273
  {
274
274
  "slug": "rich-text",
275
275
  "file": "docs/rich-text.md",
276
276
  "title": "Rich text measurement and output",
277
- "markdown": '# Rich text measurement and output\n\nOPF text arrays preserve run formatting in the document. Text payloads now use a shared mixed-style layout for composition, SVG preview, and native PPTX export. Plain string text keeps its existing layout path.\n\nThe shared fit accounts for each run\'s font family, weight, italic style, and requested point size. Run point sizes are converted to pixels at 96 DPI; default text sizes and composition minimum sizes remain canvas-relative. Fitting can shrink the run sizes together, preserving their relative sizes. Superscripts and subscripts use smaller glyphs and explicit baseline offsets. Line height accounts for the largest ascent/descent. Long tokens wrap at grapheme boundaries; spaces and explicit empty lines are retained.\n\nSVG displays bold, italic, underline, strikethrough, color, font family, size, superscript, subscript, and HTTP(S)/mailto hyperlinks. Other link schemes remain in the OPF source but are not emitted as active links. Use the same loaded font files and measurement provider for SVG and PPTX.\n\nNative PPTX output preserves these run styles and shared wrapping. Each fitted line is an editable text box, which retains measured vertical placement but is not a single continuous PowerPoint paragraph. Arbitrary PPTX import is still not a lossless rich-text round trip. Visual equality across browser and PowerPoint is not established by XML formatting checks.\n\nThe canvas supports native SVG text selection with a formatting toolbar for bold, italic, underline, strikethrough, color, font family, point size, links, and scripts. Double-click a rich text block (or focus it and press Enter) to select its full contents. For a plain text payload, start an inline edit and choose **Format text**. **Selected text** and **Replace text** replace the selected range; **Edit runs** opens structured controls. Each action validates and creates one undo step. A continuous mixed-style typing caret and IME handling remain open work; selection and formatting currently use the rendered SVG itself. List entries and descriptions use the same formatting controls at their own source paths. Complex-script shaping, bidi layout, and font-feature parity remain additional work.\n\n## Inline code and run language\n\nA run with `code: true` is an inline code span. It is set in the design\'s code font (the font scheme\'s `code` family, else Roboto Mono) unless the run names its own `fontFamily`. Only the font changes: a PPTX run cannot carry a background, so neither engine draws one. Composition measures the run in the code font (`RichTextOptions.codeFontFamily`, `TableLayoutOptions.codeFontFamily`; `composeSlide` passes `fonts.code`). Markdown backticks read and write code runs (see [Markdown](markdown.md)).\n\nA run with `lang` (a BCP-47 tag such as `fr-FR` or `ja-JP`) overrides the deck language for that run. It reaches the layout as `style.lang` on the run\'s fragments, the preview writes it as the `lang` of the run\'s text, and PPTX export writes `a:rPr/@lang` (with the run\'s own East Asian and complex-script theme fonts for that language). PPTX import reads `lang` back only when it differs from the deck language. Without `lang` a run follows the deck language.\n\n```js\nimport {fitRichText} from \'@openpresentation/opf/composition\';\nconst fit = fitRichText(\n [\'A \', {text:\'larger word\', fontSize:28, bold:true}],\n {x:0,y:0,width:400,height:200},\n 25, 16,\n {style:{fontFamily:\'Roboto\',fontWeight:400},textMeasurement},\n);\n// textMeasurement is the host\'s loaded-font measurement provider.\n// richLines contains positioned fragments, resolved styles, and baselines.\n```\n\nVerification: `node packages/javascript/test/rich-text.mjs`, composition/pagination regressions, and `pnpm test:rich-text`. The latter produces SVG, OPF, and PPTX specimens under `artifacts/rich-text/`. The SVG specimen has been visually inspected in the browser; PPTX verification currently inspects native run XML, not a PowerPoint raster comparison.\n\n## Rich headings and quotes\n\n`title`, `subtitle`, `tag` and `quote.text` accept a string or `TextRun[]` (FA-10), with the same `TextRun` definition as body text, including `color` (a hex value, scheme slot or role, or `var:<id>`), links, `superscript`/`subscript`, `cite` and `footnote`. A string keeps exactly the layout it always had.\n\n- **Composition.** A `TextRun[]` heading fits through the same rich-text layouter as body text (`composeSlide` reports a `RichTextFit` with `richLines` on the item), with the heading\'s own size, shrink floor and box rules. The heading font weight (700 for the title) is the default for every run, so `bold: false` on a run is the way to lighten a word. `layoutQuote` accepts a `TextRun[]` quote text and fits the body the same way: the quotation marks join the first and last run (so run indexes and marker paths never shift) and the part reports `runs` and a `RichTextFit`; the footer (attribution and source) stays a plain string.\n- **Citations.** A marked heading run draws its marker after the run, and its note joins the slide\'s footnote area. Numbering follows reading order, with the heading group first: `tag`, `title`, `subtitle`, then regions, blocks and the root payload (see [footnotes-citations-captions.md](footnotes-citations-captions.md)).\n- **Variables.** A whole-field `var:<id>` whose text variable holds `TextRun[]` keeps the runs in a heading; `{{id}}` inside a run\'s text resolves as in body text.\n- **Markdown.** `# Title`, `## Subtitle` and the quote text keep [inline formatting](markdown.md#inline-text) both ways.\n- **Validate.** Contrast is checked for each run color; a title of runs counts as a title (`missing-slide-title`, `duplicate-slide-title` compare plain text); link text in a heading is checked like body link text.\n- **Pagination.** A long rich quote splits by text offset like body text, each piece keeping its run formatting; the heading group repeats on every page.\n\n```json\n{ "title": ["Revenue grew ", { "text": "28%", "color": "accent1", "cite": "annual-report" }], "quote": { "text": ["Cut review time by ", { "text": "40%", "bold": true }, " in a quarter"], "attribution": "VP Operations" } }\n```\n\n## Headless range editing\n\nAny agent or application can use the same immutable helpers, without a browser or AI service:\n\n```js\nimport {formatRichTextRange, replaceRichTextRange} from \'@openpresentation/opf-editor/rich-text\';\nconst path = \'slides.0.text\';\nconst next = formatRichTextRange(editor.get(path), 0, 5, {bold: true});\neditor.set(path, next, {rejectInvalid: true});\n// Other helpers: replaceRichTextRange(value, start, end, replacement), richTextContent(value).\n```\n\nOffsets are UTF-16 offsets, matching DOM Selection. They must fall on whole grapheme boundaries; ranges that split surrogate pairs, combining sequences, or emoji sequences are rejected. Formatting preserves unselected text, run metadata, and links. A `null` style removes an override, while `false` explicitly disables a boolean style. Superscript and subscript are mutually exclusive when applying a new script style. Text replacement inherits the first selected run\'s style; insertion at a boundary inherits the preceding run. Pass the result through whole-document validation before saving.\n\nThese helpers are published through `@openpresentation/opf-editor/rich-text` in editor 0.8.0. Use the current editor 0.12.1 with core 0.13.0, renderer 0.13.1 and PPTX 0.13.2 on Node 24. The [compatibility matrix](compatibility-matrix.md) separates shipped APIs from remaining canvas, font and native Office gates; package availability does not establish arbitrary PPTX round-trip or pixel parity.\n\nBrowser verification: `pnpm demo:editor`, then open `/rich-text-tests.html` on the demo server. The harness covers forward/reverse cross-run selection, shared-renderer source offsets, selection restoration after reflow, styles, links, replacement, undo, stale selection invalidation, plain-text entry, structured controls, and disposal.\n\n## Lists and descriptions\n\n`items` and `bullets` use the shared `fitList` API, including payloads explicitly marked `type: "text"` with a `bullets` field. Entries accept strings, run arrays, or objects with `text` and `level`; `items` objects also accept `description`. Rich text is measured without flattening styles. Descriptions default to 82% of the body size. Explicit run point sizes stay absolute until fitting shrinks the whole list uniformly.\n\n```js\nimport {fitList} from \'@openpresentation/opf/composition\';\nconst fit = fitList([\n {text: [\'A \', {text: \'recommendation\', bold: true}],\n description: [{text: \'Supporting evidence\', italic: true}], level: 1},\n], {x: 0, y: 0, width: 500, height: 300}, 25, 16,\n{style: {fontFamily: \'Roboto\', fontWeight: 400, path: \'slides.0.items\'}, textMeasurement});\n// listEntries contains text/description boxes, rich lines, markers and source paths.\n```\n\nEach nesting level adds an indent of 1.1 times the fitted body font size. Wrapped lines and descriptions align with the entry text, while character markers cycle through three shapes. Levels are not silently capped at three; excessive indentation reports overflow. Composition scoring and pagination use the measured list height, splitting only between complete entries and preserving descriptions, levels and runs.\n\nThe canvas edits strings inline and rich arrays through selection and formatting. List containers still expose structural properties for adding, removing and reordering entries. Native PPTX uses one editable box per fitted line, with a native bullet only on the first body line. Bullet font, size and color are explicit. PowerPoint paragraph levels stop at eight; deeper OPF levels retain their measured visual offset. Reimport uses heuristics for adjacent bullet boxes and is not a lossless reconstruction of descriptions or rich list structure.\n\nImage bullets and continuous rich typing remain outstanding. Native PowerPoint raster comparison is still needed before claiming pixel parity. Verification: `pnpm test:lists` writes OPF/SVG/PPTX specimens to `artifacts/lists/` and checks native paragraph validity, bullet properties, text and indent coordinates. `/list-tests.html` and its packed-package equivalent cover 19 canvas editing/undo checks.\n\n## Citations and footnotes\n\nA run that cites a source or carries an inline note gets a superscript marker directly after it (core 0.11.5 and later; RR-34): `{"text": "doubled", "cite": "gartner-2026"}` cites an entry of the deck\'s top-level `references` list (`{id, text, url?}`), `cite: ["a", "b"]` shows `1,2`, and `{"text": "grew", "footnote": "Unaudited."}` lists an inline note. Markers are numbered per deck in reading order of first use; the same reference id keeps its number, every footnote takes a new one. The slide then carries a footnote area above its footer band listing `<n> <text>` for the notes it uses, and the content area shrinks by that height. Markers are supported in `text`, `bullets` and list item runs (`cite-unsupported-location` elsewhere); an unknown id is `cite-unknown-reference`, an uncited reference the lint warning `opf/unused-reference`.\n\nIn the shared layout a marker is a fragment `{kind: "marker"}` after the run\'s last fragment with zero source length, 0.7 of the run\'s size and a raise of 0.3 of its own size, so run indexes, `data-opf-text-*` offsets and wrapping stay those of the authored runs and the exporter writes a native superscript run (`baseline="30000"`). `referencesSlide(presentation, {title})` builds an ordinary list slide of the cited references. Details and the engine mapping: [footnotes, citations and captions](footnotes-citations-captions.md).\n'
277
+ "markdown": '# Rich text measurement and output\n\nOPF text arrays preserve run formatting in the document. Text payloads now use a shared mixed-style layout for composition, SVG preview, and native PPTX export. Plain string text keeps its existing layout path.\n\nThe shared fit accounts for each run\'s font family, weight, italic style, and requested point size. Run point sizes are converted to pixels at 96 DPI; default text sizes and composition minimum sizes remain canvas-relative. Fitting can shrink the run sizes together, preserving their relative sizes. Superscripts and subscripts use smaller glyphs and explicit baseline offsets. Line height accounts for the largest ascent/descent. Long tokens wrap at grapheme boundaries; spaces and explicit empty lines are retained.\n\nSVG displays bold, italic, underline, strikethrough, color, font family, size, superscript, subscript, and HTTP(S)/mailto hyperlinks. Other link schemes remain in the OPF source but are not emitted as active links. Use the same loaded font files and measurement provider for SVG and PPTX.\n\nNative PPTX output preserves these run styles and shared wrapping. Each fitted line is an editable text box, which retains measured vertical placement but is not a single continuous PowerPoint paragraph. Arbitrary PPTX import is still not a lossless rich-text round trip. Visual equality across browser and PowerPoint is not established by XML formatting checks.\n\nThe canvas supports native SVG text selection with a formatting toolbar for bold, italic, underline, strikethrough, color, font family, point size, links, and scripts. Double-click a rich text block (or focus it and press Enter) to select its full contents. For a plain text payload, start an inline edit and choose **Format text**. **Selected text** and **Replace text** replace the selected range; **Edit runs** opens structured controls. Each action validates and creates one undo step. A continuous mixed-style typing caret and IME handling remain open work; selection and formatting currently use the rendered SVG itself. List entries and descriptions use the same formatting controls at their own source paths. Complex-script shaping, bidi layout, and font-feature parity remain additional work.\n\n## Inline code and run language\n\nA run with `code: true` is an inline code span. It is set in the design\'s code font (the font scheme\'s `code` family, else Roboto Mono) unless the run names its own `fontFamily`. Only the font changes: a PPTX run cannot carry a background, so neither engine draws one. Composition measures the run in the code font (`RichTextOptions.codeFontFamily`, `TableLayoutOptions.codeFontFamily`; `composeSlide` passes `fonts.code`). Markdown backticks read and write code runs (see [Markdown](markdown.md)).\n\nA run with `lang` (a BCP-47 tag such as `fr-FR` or `ja-JP`) overrides the deck language for that run. It reaches the layout as `style.lang` on the run\'s fragments, the preview writes it as the `lang` of the run\'s text, and PPTX export writes `a:rPr/@lang` (with the run\'s own East Asian and complex-script theme fonts for that language). PPTX import reads `lang` back only when it differs from the deck language. Without `lang` a run follows the deck language.\n\n```js\nimport {fitRichText} from \'@openpresentation/opf/composition\';\nconst fit = fitRichText(\n [\'A \', {text:\'larger word\', fontSize:28, bold:true}],\n {x:0,y:0,width:400,height:200},\n 25, 16,\n {style:{fontFamily:\'Roboto\',fontWeight:400},textMeasurement},\n);\n// textMeasurement is the host\'s loaded-font measurement provider.\n// richLines contains positioned fragments, resolved styles, and baselines.\n```\n\nVerification: `node packages/javascript/test/rich-text.mjs`, composition/pagination regressions, and `pnpm test:rich-text`. The latter produces SVG, OPF, and PPTX specimens under `artifacts/rich-text/`. The SVG specimen has been visually inspected in the browser; PPTX verification currently inspects native run XML, not a PowerPoint raster comparison.\n\n## Rich headings and quotes\n\n`title`, `subtitle`, `tag` and `quote.text` accept a string or `TextRun[]` (FA-10), with the same `TextRun` definition as body text, including `color` (a hex value, scheme slot or role, or `var:<id>`), links, `superscript`/`subscript`, `cite` and `footnote`. A string keeps exactly the layout it always had.\n\n- **Composition.** A `TextRun[]` heading fits through the same rich-text layouter as body text (`composeSlide` reports a `RichTextFit` with `richLines` on the item), with the heading\'s own size, shrink floor and box rules. The heading font weight (700 for the title) is the default for every run, so `bold: false` on a run is the way to lighten a word. `layoutQuote` accepts a `TextRun[]` quote text and fits the body the same way: the quotation marks join the first and last run (so run indexes and marker paths never shift) and the part reports `runs` and a `RichTextFit`; the footer (attribution and source) stays a plain string.\n- **Citations.** A marked heading run draws its marker after the run, and its note joins the slide\'s footnote area. Numbering follows reading order, with the heading group first: `tag`, `title`, `subtitle`, then regions, blocks and the root payload (see [footnotes-citations-captions.md](footnotes-citations-captions.md)).\n- **Variables.** A whole-field `var:<id>` whose text variable holds `TextRun[]` keeps the runs in a heading; `{{id}}` inside a run\'s text resolves as in body text.\n- **Markdown.** `# Title`, `## Subtitle` and the quote text keep [inline formatting](markdown.md#inline-text) both ways.\n- **Validate.** Contrast is checked for each run color; a title of runs counts as a title (`missing-slide-title`, `duplicate-slide-title` compare plain text); link text in a heading is checked like body link text.\n- **Pagination.** A long rich quote splits by text offset like body text, each piece keeping its run formatting; the heading group repeats on every page.\n\n```json\n{ "title": ["Revenue grew ", { "text": "28%", "color": "accent1", "cite": "annual-report" }], "quote": { "text": ["Cut review time by ", { "text": "40%", "bold": true }, " in a quarter"], "attribution": "VP Operations" } }\n```\n\n## Headless range editing\n\nAny agent or application can use the same immutable helpers, without a browser or AI service:\n\n```js\nimport {formatRichTextRange, replaceRichTextRange} from \'@openpresentation/opf-editor/rich-text\';\nconst path = \'slides.0.text\';\nconst next = formatRichTextRange(editor.get(path), 0, 5, {bold: true});\neditor.set(path, next, {rejectInvalid: true});\n// Other helpers: replaceRichTextRange(value, start, end, replacement), richTextContent(value).\n```\n\nOffsets are UTF-16 offsets, matching DOM Selection. They must fall on whole grapheme boundaries; ranges that split surrogate pairs, combining sequences, or emoji sequences are rejected. Formatting preserves unselected text, run metadata, and links. A `null` style removes an override, while `false` explicitly disables a boolean style. Superscript and subscript are mutually exclusive when applying a new script style. Text replacement inherits the first selected run\'s style; insertion at a boundary inherits the preceding run. Pass the result through whole-document validation before saving.\n\nThese helpers are published through `@openpresentation/opf-editor/rich-text` in editor 0.8.0. Use the current editor 0.14.2 with core 0.14.0, renderer 0.14.0 and PPTX 0.14.0 on Node 24. The [compatibility matrix](compatibility-matrix.md) separates shipped APIs from remaining canvas, font and native Office gates; package availability does not establish arbitrary PPTX round-trip or pixel parity.\n\nBrowser verification: `pnpm demo:editor`, then open `/rich-text-tests.html` on the demo server. The harness covers forward/reverse cross-run selection, shared-renderer source offsets, selection restoration after reflow, styles, links, replacement, undo, stale selection invalidation, plain-text entry, structured controls, and disposal.\n\n## Lists and descriptions\n\n`items` and `bullets` use the shared `fitList` API, including payloads explicitly marked `type: "text"` with a `bullets` field. Entries accept strings, run arrays, or objects with `text` and `level`; `items` objects also accept `description`. Rich text is measured without flattening styles. Descriptions default to 82% of the body size. Explicit run point sizes stay absolute until fitting shrinks the whole list uniformly.\n\n```js\nimport {fitList} from \'@openpresentation/opf/composition\';\nconst fit = fitList([\n {text: [\'A \', {text: \'recommendation\', bold: true}],\n description: [{text: \'Supporting evidence\', italic: true}], level: 1},\n], {x: 0, y: 0, width: 500, height: 300}, 25, 16,\n{style: {fontFamily: \'Roboto\', fontWeight: 400, path: \'slides.0.items\'}, textMeasurement});\n// listEntries contains text/description boxes, rich lines, markers and source paths.\n```\n\nEach nesting level adds an indent of 1.1 times the fitted body font size. Wrapped lines and descriptions align with the entry text, while character markers cycle through three shapes. Levels are not silently capped at three; excessive indentation reports overflow. Composition scoring and pagination use the measured list height, splitting only between complete entries and preserving descriptions, levels and runs.\n\nThe canvas edits strings inline and rich arrays through selection and formatting. List containers still expose structural properties for adding, removing and reordering entries. Native PPTX uses one editable box per fitted line, with a native bullet only on the first body line. Bullet font, size and color are explicit. PowerPoint paragraph levels stop at eight; deeper OPF levels retain their measured visual offset. Reimport uses heuristics for adjacent bullet boxes and is not a lossless reconstruction of descriptions or rich list structure.\n\nImage bullets and continuous rich typing remain outstanding. Native PowerPoint raster comparison is still needed before claiming pixel parity. Verification: `pnpm test:lists` writes OPF/SVG/PPTX specimens to `artifacts/lists/` and checks native paragraph validity, bullet properties, text and indent coordinates. `/list-tests.html` and its packed-package equivalent cover 19 canvas editing/undo checks.\n\n## Citations and footnotes\n\nA run that cites a source or carries an inline note gets a superscript marker directly after it (core 0.11.5 and later; RR-34): `{"text": "doubled", "cite": "gartner-2026"}` cites an entry of the deck\'s top-level `references` list (`{id, text, url?}`), `cite: ["a", "b"]` shows `1,2`, and `{"text": "grew", "footnote": "Unaudited."}` lists an inline note. Markers are numbered per deck in reading order of first use; the same reference id keeps its number, every footnote takes a new one. The slide then carries a footnote area above its footer band listing `<n> <text>` for the notes it uses, and the content area shrinks by that height. Markers are supported in `text`, `bullets` and list item runs (`cite-unsupported-location` elsewhere); an unknown id is `cite-unknown-reference`, an uncited reference the lint warning `opf/unused-reference`.\n\nIn the shared layout a marker is a fragment `{kind: "marker"}` after the run\'s last fragment with zero source length, 0.7 of the run\'s size and a raise of 0.3 of its own size, so run indexes, `data-opf-text-*` offsets and wrapping stay those of the authored runs and the exporter writes a native superscript run (`baseline="30000"`). `referencesSlide(presentation, {title})` builds an ordinary list slide of the cited references. Details and the engine mapping: [footnotes, citations and captions](footnotes-citations-captions.md).\n'
278
278
  },
279
279
  {
280
280
  "slug": "schema-reference",
281
281
  "file": "docs/schema-reference.md",
282
282
  "title": "OPF Presentation Schema Reference",
283
- "markdown": "# OPF Presentation Schema Reference\n\nThis reference documents the author-facing shape of a complete `*.opf.json` presentation document. It summarizes the canonical schema in `spec/schemas/opf.schema.json`; the schema remains the source of truth for validators.\n\n## Document Contract\n\n- Schema id: `https://openpresentation.org/schema/opf/v1`\n- Required top-level fields: `slides`\n- Additional top-level fields: not allowed\n\n## Top-Level Fields\n\n| Field | Required | Type | Notes |\n| --- | --- | --- | --- |\n| `$schema` | no | `const:\"https://openpresentation.org/schema/opf/v1\"` | Optional OPF schema version. When omitted, validators and engines should assume the latest supported OPF schema. |\n| `name` | no | `string` | Display name of the presentation for GUI/TUI lists, library/search indexing, OS-level metadata, and default export filenames. This is deck identity, not slide content. Use slides[].title and slides[].subtitle for text... |\n| `description` | no | `string` | Free-form prose describing what this presentation is about. Used by agents and humans as a deck-level summary; complements purpose (the goal) and narrative (the structured storyline). Round-trips to OOXML 'docProps/co... |\n| `filename` | no | `string` | Optional base filename for exports (without extension). The opf CLI (render, export) and the editor strip a trailing .pptx, .pdf, .png, or .svg (case-insensitive) and append the target format's extension. When omitted... |\n| `organization` | no | `oneOf:ref:Organization / array<ref:Organization>` | Organization associated with the presentation, usually the presenting company. Array form supports hosts, partners, clients, and sponsors. The primary organization (Organization.role 'primary', else the first item) su... |\n| `speaker` | no | `oneOf:ref:Speaker / array<ref:Speaker>` | Person presenting the deck. Array form supports panels and multi-speaker decks. The first speaker is the primary speaker: the built-in variables 'speaker.<field>' and the 'speaker' header/footer field read it, 'speake... |\n| `author` | no | `oneOf:string / array<string>` | Optional credit for the person who authored or contributed to the deck, distinct from speaker. Array form supports multiple contributors. Round-trips to OOXML 'docProps/core.xml' as '<dc:creator>' (semicolon-joined wh... |\n| `audience` | no | `oneOf:string / ref:Audience / array<oneOf:string / ref:Audience>` | Intended audiences for the presentation. Accepts any of: - A single string shorthand: free-form description ('Series B investors'), an audiences catalog id ('executive'), an HTTPS URL, or a 'pkg:' reference. - A singl... |\n| `purpose` | no | `oneOf:string / ref:Purpose` | Primary goal of the presentation. Accepts either: - A string shorthand: free-form goal ('Raise a Series B round of $30M'), a purposes catalog id ('decide', 'align'), an HTTPS URL, or a 'pkg:' reference. - An inline Pu... |\n| `language` | no | `oneOf:string / ref:Language` | Language for the presentation content. Accepts either: - A string shorthand: a BCP-47 language tag ('en-US', 'en-GB', 'ja-JP', 'fr'), a languages catalog id ('english', 'japanese'), an HTTPS URL, or a 'pkg:' reference... |\n| `tone` | no | `oneOf:string / ref:Tone` | Desired tone for the presentation. Accepts either: - A string shorthand: a tones catalog id ('formal'), an HTTPS URL, or a 'pkg:' reference. - An inline Tone object for custom tone metadata or catalog-backed overrides... |\n| `takeaway` | no | `oneOf:string / array<string>` | Audience-facing takeaway the presentation should leave behind. Array form supports multiple takeaways. Deck-level intent used by AI to seed and pressure-test slide content. |\n| `duration` | no | `integer` | Target presentation duration, as an integer number of minutes. The opf-render presenter view counts the elapsed time against it, and core validate warns when it lies outside the resolved narrative's 'duration' range.... |\n| `tags` | no | `array<string>` | Free-form labels used for categorization, search, and filtering. Lowercase kebab-case is recommended for consistency across a deck library. |\n| `design` | no | `ref:Design` | Optional design system covering theme, color scheme, font scheme, dimensions, background, logo, watermark, header, and footer applied to the deck. When omitted, engines use their default design configuration. |\n| `variables` | no | `ref:Variables` | Optional named variables: deck colors referenced as 'var:<id>' (the original use), and typed content variables (text, number, date, image, url, list) referenced inline as '{{<id>}}' or whole as 'var:<id>'. Variables a... |\n| `template` | no | `boolean` | Marks this document as a template: an incomplete OPF file. A template declares variables (top-level 'variables') and references them from content, and may leave required variables unfilled; validation then reports the... |\n| `narrative` | no | `string` | The deck's narrative plan, by reference: the 'id' of a 'narratives' catalog record, an HTTPS URL pointing at a record file, or a 'pkg:' reference to a locally-installed package. The deck holds only this pointer; the p... |\n| `slides` | yes | `array<ref:Slide>` | Ordered array of slides that make up the presentation. |\n| `references` | no | `array<ref:Reference>` | Sources that text runs cite with 'cite'. Ids are unique. A cited reference is listed in the footnote area of every slide that cites it, with a marker number assigned per deck in order of first use; a reference no run... |\n| `datasets` | no | `ref:Datasets` | Optional shared data tables, keyed by id. A chart ('chart.data': { \"dataset\": \"<id>\" }) or a table ('table': { \"dataset\": \"<id>\" }) references one instead of holding its own copy; engines inline the reference before c... |\n| `assets` | no | `ref:Assets` | Optional reusable asset registry for images, data files, videos, documents, fonts, and other resources referenced elsewhere in the deck via 'asset:<id>' strings. |\n| `catalogs` | no | `ref:Catalogs` | Optional per-kind catalog overrides. Each kind may declare a non-default 'source' and/or inline 'records' that override or supplement the default catalog at https://www.pptx.gallery/<kind>. References elsewhere in the... |\n| `extensions` | no | `object` | Custom data passthrough for agent workflows; ignored by the engine but preserved across read/write round-trips. |\n\n## Object And Type Reference\n\n### Assets\n\n- Type: `object`\n- Required fields: none\n- Purpose: Reusable asset registry for resources used by slides, charts, metadata, and design. Keys are stable asset ids referenced elsewhere as 'asset:<id>'. Each asset can be a source string or an object with src plus optional metadata.\n\n_No named properties._\n\n\n### Asset\n\n- Type: `oneOf:string / object`\n- Required fields: none\n- Purpose: Reusable or inline resource. A string is shorthand for { \"src\": value }. Source strings accept 'asset:<id>' references, HTTPS URLs, data URIs, relative paths resolved against the OPF file location, or local filesystem paths. Use object form when metadata such as alt text, title or mediaType matters.\n\n_No named properties._\n\n\n### Audience\n\n- Type: `anyOf:schema / schema`\n- Required fields: none\n- Purpose: Inline audience metadata for the presentation. Use 'id' to reference an audiences catalog record and override selected fields, or use 'name' for a custom inline audience.\n- Conditional requirement: `id` or `name`\n\n| Field | Required | Type | Notes |\n| --- | --- | --- | --- |\n| `id` | no | `string` | Optional audiences catalog id to resolve before applying inline overrides. |\n| `name` | no | `string` | Human-readable audience name shown in pickers. |\n| `summary` | no | `string` | One-sentence positioning of the audience. |\n| `description` | no | `string` | Longer prose describing the audience and how to address them. |\n| `seniority` | no | `enum:ic \\| manager \\| director \\| vp \\| c-suite \\| mixed` | Typical seniority level of the audience. |\n| `technicalFluency` | no | `enum:low \\| medium \\| high \\| mixed` | Typical technical fluency of the audience. |\n| `decisionPower` | no | `enum:informational \\| advisory \\| decision-maker` | Whether the audience is expected to be informed, advise, or decide. |\n| `attentionBudgetMinutes` | no | `number` | Realistic upper bound on focused attention for a single presentation, in minutes. |\n| `recommendedNarratives` | no | `array<string>` | Soft cross-link: narrative-catalog ids that work well for this audience. |\n| `recommendedTones` | no | `array<string>` | Soft cross-link: tone-catalog ids that work well for this audience. |\n| `tags` | no | `array<string>` | Free-form labels for filtering and search. |\n\n\n### Purpose\n\n- Type: `anyOf:schema / schema`\n- Required fields: none\n- Purpose: Inline purpose metadata for the presentation. Use 'id' to reference a purposes catalog record and override selected fields, or use 'name' for a custom inline purpose.\n- Conditional requirement: `id` or `name`\n\n| Field | Required | Type | Notes |\n| --- | --- | --- | --- |\n| `id` | no | `string` | Optional purposes catalog id to resolve before applying inline overrides. |\n| `name` | no | `string` | Human-readable purpose name shown in pickers. |\n| `summary` | no | `string` | One-sentence positioning of the purpose. |\n| `description` | no | `string` | Longer prose describing when to use this purpose and how it should shape a deck. |\n| `outcome` | no | `string` | Desired audience outcome after the presentation. |\n| `successCriteria` | no | `array<string>` | Observable signals that the deck accomplished this purpose. |\n| `recommendedNarratives` | no | `array<string>` | Soft cross-link: narrative-catalog ids that work well for this purpose. |\n| `recommendedTones` | no | `array<string>` | Soft cross-link: tone-catalog ids that work well for this purpose. |\n| `tags` | no | `array<string>` | Free-form labels for filtering and search. |\n\n\n### Language\n\n- Type: `anyOf:schema / schema`\n- Required fields: none\n- Purpose: Inline language metadata for the presentation. Use 'id' to reference a languages catalog record and override selected fields, or use 'bcp47' for a custom language tag without a catalog record.\n- Conditional requirement: `id` or `bcp47`\n\n| Field | Required | Type | Notes |\n| --- | --- | --- | --- |\n| `id` | no | `string` | Optional languages catalog id to resolve before applying inline overrides. |\n| `name` | no | `string` | Human-readable language name. |\n| `bcp47` | no | `string` | BCP-47 language tag used for locale-aware rendering, proofing, and accessibility metadata. Use 'en-GB' for UK English; 'en-UK' is not a valid BCP-47 region form. |\n| `ooxmlLang` | no | `string` | Curated culture tag for OOXML text-run language attributes (a:rPr/@lang, a:endParaRPr/@lang), in the language-[Script-]REGION form Office recognizes (e.g. 'ja-JP', 'ar-SA', 'ms-MY', 'nb-NO', 'fil-PH', 'zh-CN'). Engine... |\n| `code` | no | `string` | ISO 639-3 or 639-2 language code carried for engines that prefer ISO codes. |\n| `direction` | no | `enum:ltr \\| rtl` | Base text direction for the language. When omitted, engines derive it from the script: Arabic (Arab), Hebrew (Hebr), Syriac (Syrc), Thaana (Thaa), N'Ko (Nkoo), Adlam (Adlm), Samaritan (Samr), Mandaic (Mand) and Hanifi... |\n| `script` | no | `string` | ISO 15924 script code of the language's writing system. The script selects the OOXML font slot the language's text uses: East Asian scripts (Hans, Hant, Hani, Jpan, Kore, Hang, Hira, Kana, Bopo, Yiii) use the eastAsia... |\n| `fontScheme` | no | `string` | Default font-scheme id for this language when targeting PowerPoint output. Resolves against catalogs.fontSchemes the same way design.fontScheme or design.fontScheme.id does. Its major/minor families fill the language'... |\n| `googleFontScheme` | no | `string` | Default font-scheme id for this language when targeting Google Slides output. Resolves against catalogs.fontSchemes the same way design.fontScheme or design.fontScheme.id does. Used in place of 'fontScheme' when resol... |\n| `summary` | no | `string` | One-sentence note about coverage or font defaults. |\n| `description` | no | `string` | Longer prose describing the language record and any font-pairing rationale. |\n| `tags` | no | `array<string>` | Free-form labels for filtering and search. |\n\n\n### Tone\n\n- Type: `anyOf:schema / schema`\n- Required fields: none\n- Purpose: Inline tone metadata for the presentation. Use 'id' to reference a tones catalog record and override selected fields, or use 'name' for a custom inline tone.\n- Conditional requirement: `id` or `name`\n\n| Field | Required | Type | Notes |\n| --- | --- | --- | --- |\n| `id` | no | `string` | Optional tones catalog id to resolve before applying inline overrides. |\n| `name` | no | `string` | Human-readable tone name shown in pickers. |\n| `summary` | no | `string` | One-sentence positioning of the tone. |\n| `description` | no | `string` | Longer prose describing the tone and the kinds of decks it suits. |\n| `voiceCues` | no | `array<string>` | Short directives that shape AI generation toward this tone. |\n| `avoid` | no | `array<string>` | Anti-patterns that AI generation should not produce when this tone is active. |\n| `samplePhrases` | no | `array<string>` | Short example phrases that exemplify this tone. |\n| `recommendedNarratives` | no | `array<string>` | Soft cross-link: narrative-catalog ids this tone pairs well with. |\n| `tags` | no | `array<string>` | Free-form labels for filtering and search. |\n\n\n### Organization\n\n- Type: `object`\n- Required fields: `id`, `name`\n- Purpose: An organization associated with the presentation, typically the presenting company, but also hosts, partners, clients, or sponsors. The primary organization (role 'primary', else the first one) supplies the default deck logo (unless design.logo overrides it), the 'organization' header/footer field (its name) and the 'socials' field (its socials). Every field is also a built-in variable ('{{organization.name}}', 'var:organization.logo', 'organization.<id>.<field>'). Nothing else about an organ...\n\n| Field | Required | Type | Notes |\n| --- | --- | --- | --- |\n| `id` | yes | `string` | Stable identifier for the organization, used to reference it from Speaker.organizationId and to address it in built-in variables as 'organization.<id>.<field>'. Must be unique among organizations (a duplicate is a val... |\n| `name` | yes | `string` | Display name. Drawn by the 'organization' header/footer field for the primary organization; the built-in variable 'organization.name' (or 'organization.<id>.name') carries it into any string. |\n| `legalName` | no | `string` | Optional legal entity name when it differs from the display name. Never drawn automatically; available as the built-in variable 'organization.legalName'. |\n| `logo` | no | `ref:Asset` | Source for the organization's logo image. Accepts an HTTPS URL, data URI, relative path (resolved against the OPF file location), local path, or 'asset:<id>' reference. Common formats are SVG (preferred for vector log... |\n| `domain` | no | `string` | Bare internet domain for the organization. Never drawn automatically and not used to look up assets; available as the built-in variable 'organization.domain'. |\n| `email` | no | `string` | General contact email for the organization. Never drawn automatically; available as the built-in variable 'organization.email'. |\n| `phone` | no | `string` | Main contact phone number for the organization. E.164 format is recommended. Never drawn automatically; available as the built-in variable 'organization.phone'. |\n| `tagline` | no | `string` | Short tagline. Never drawn automatically (cover slides do not show it); available as the built-in variable 'organization.tagline', for example in a footer 'text' of '{{organization.tagline}}'. |\n| `role` | no | `enum:primary \\| partner \\| client \\| sponsor \\| host` | Role of the organization relative to the presentation. Only 'primary' has behavior: it selects the primary organization (deck logo, 'organization' and 'socials' header/footer fields, and the 'organization.<field>' bui... |\n| `socials` | no | `ref:Socials` | Optional social media handles or URLs for the organization. The primary organization's socials render in header/footer zones that set socials: true; otherwise they are authoring metadata. |\n\n\n### Speaker\n\n- Type: `object`\n- Required fields: `id`, `name`\n- Purpose: A person presenting the deck. A speaker is drawn only through built-in variables ('{{speaker.name}}' inside any string, 'var:speaker.photo' as a whole image field) and the 'speaker' header/footer field (the first speaker's name and title). No layout, cover or bio slide places a speaker on its own. See docs/templates-and-variables.md.\n\n| Field | Required | Type | Notes |\n| --- | --- | --- | --- |\n| `id` | yes | `string` | Stable identifier for the speaker. Must be unique among speakers (a duplicate is a validation error). Addresses this speaker in built-in variables as 'speaker.<id>.<field>', such as '{{speaker.alice.title}}'. |\n| `name` | yes | `string` | Display name. Built-in variable 'speaker.name' (first speaker) or 'speaker.<id>.name'; the first speaker's name is also drawn by the 'speaker' header/footer field and every name is listed by 'speakers'. |\n| `title` | no | `string` | Role or title. Built-in variable 'speaker.title'; the 'speaker' header/footer field draws it after the name ('Alice Chen, VP of Engineering'). |\n| `photo` | no | `ref:Asset` | Source for the speaker's headshot image. Accepts an HTTPS URL, data URI, relative path (resolved against the OPF file location), local path, or 'asset:<id>' reference. Common formats are JPG or PNG; SVG is not appropr... |\n| `email` | no | `string` | Contact email. Never drawn automatically; available as the built-in variable 'speaker.email'. |\n| `phone` | no | `string` | Contact phone number for the speaker. E.164 format is recommended. Never drawn automatically; available as the built-in variable 'speaker.phone'. |\n| `bio` | no | `string` | Short biographical paragraph. Never drawn automatically; available as the built-in variable 'speaker.bio', for a bio or 'about the speaker' slide you write. |\n| `organizationId` | no | `string` | Reference to an Organization.id in organization; it must name an existing organization (a validation error otherwise). Attribution metadata for hosts: no engine draws it or changes which organization is primary becaus... |\n| `socials` | no | `ref:Socials` | Optional social media handles or URLs for the speaker. Authoring metadata only: nothing draws speaker socials and there is no built-in variable for them (the 'socials' header/footer field draws the primary organizatio... |\n\n\n### Socials\n\n- Type: `object`\n- Required fields: none\n- Purpose: Social media handles or URLs, keyed by platform id from the 'socialPlatforms' catalog. Each value is a string either a full URL or a platform handle (e.g., '@acme'). The catalog record for each platform carries the URL pattern and handle prefix that engines use to render and link the profile URL, plus brand color and themed icons as catalog metadata for authoring UIs (engines render the profile URL, not icons or brand colors). Keys resolve to the 'id' of a 'socialPlatforms' catalog record. Re...\n\n_No named properties._\n\n\n### Design\n\n- Type: `object`\n- Required fields: none\n- Purpose: Visual design system applied to the presentation; individual slides may override fields via Slide.design.\n\n| Field | Required | Type | Notes |\n| --- | --- | --- | --- |\n| `theme` | no | `oneOf:string / ref:Theme` | Theme for the deck. Accepts two forms: - String shorthand: 'design.theme = \"minimal\"'. Bare id, HTTPS URL, or 'pkg:' reference resolved as the 'id' of a 'themes' catalog record. - Object form: a Theme with an optional... |\n| `colorScheme` | no | `oneOf:string / ref:ColorScheme` | Color scheme for the presentation. Accepts two forms: - String shorthand: 'design.colorScheme = \"cool-horizon\"'. Bare id, HTTPS URL, or 'pkg:' reference resolved as the 'id' of a 'colorSchemes' catalog record. - Objec... |\n| `fontScheme` | no | `oneOf:string / ref:FontScheme` | Font scheme for heading, body, accent, and code text. Accepts two forms: - String shorthand: 'design.fontScheme = \"aptos\"'. Bare id, HTTPS URL, or 'pkg:' reference resolved as the 'id' of a 'fontSchemes' catalog recor... |\n| `dimensions` | no | `oneOf:ref:DimensionPreset / ref:Dimensions` | Slide dimensions and aspect ratio. String shorthand such as 'widescreen' is equivalent to { preset: 'widescreen' }. |\n| `background` | no | `oneOf:ref:BackgroundShortcut / ref:Background` | Default slide background applied across the deck unless overridden on a slide. String shorthand accepts theme slots ('light1', 'light2', 'dark1', 'dark2') or hex colors; object forms support theme, solid, gradient, im... |\n| `logo` | no | `oneOf:ref:Asset / ref:LogoSet` | Deck logo assets used by covers, section dividers, headers, footers and picture bullets. A string or Asset object is the default logo source; the LogoSet object form provides light/dark, stacked, icon, and wordmark va... |\n| `watermark` | no | `oneOf:const:false / ref:Asset / ref:Watermark` | Optional decorative watermark applied across slides: an image or a text stamp, in the fixed frame and at the opacity described on Watermark. Use false to suppress an inherited watermark in slide-level design; a string... |\n| `header` | no | `oneOf:const:false / ref:HeaderFooter` | Repeated header furniture rendered outside the main slide content. Use false to suppress an inherited header. |\n| `footer` | no | `oneOf:const:false / ref:HeaderFooter` | Repeated footer furniture rendered outside the main slide content. Use false to suppress an inherited footer. |\n| `titleAlignment` | no | `enum:left \\| center \\| right` | Default horizontal alignment for title placeholders in resolved layouts. Effective value: the slide's design, then the deck's design, then the layout record's design.titleAlignment, then the engine default. |\n| `contentAlignment` | no | `enum:left \\| center \\| right` | Default horizontal alignment for body/content regions in resolved layouts. Effective value: the slide's design, then the deck's design, then the layout record's design.contentAlignment, then the engine default. A cove... |\n| `contentBox` | no | `boolean` | Whether body/content regions are rendered inside a visible card or surface. Effective value: the slide's design, then the deck's design, then the layout record's design.contentBox, then the engine default. |\n| `slideImage` | no | `oneOf:ref:Asset / object` | Optional slide-level image, separate from content images. It applies to a slide that sets its own design.slideImage, and to slides whose layout record sets design.slideImage or whose root image is the same source as a... |\n| `contentDirection` | no | `enum:horizontal \\| vertical` | Axis along which parallel body content is arranged. Sets the root arrangement mode of blocks and root payloads when no composition.mode is set on the slide or on its layout record: 'vertical' is column, 'horizontal' i... |\n| `chartPrimary` | no | `enum:none \\| top \\| bottom \\| left \\| right` | Where the primary chart sits relative to supporting content. Effective value: slide design, then deck design, then the layout record's design.chartPrimary. When the slide has no promoted regions and no composition.mod... |\n| `imageFill` | no | `enum:crop \\| fit` | How images fill their allocated region: crop covers the region, fit shows the whole image. Effective value: the slide's design, then the deck's design, then the layout record's design.imageFill, then the engine default. |\n| `listBullet` | no | `enum:character \\| image` | Marker style for items and bullets lists. Effective value: the slide's design, then the deck's design, then the layout record's design.listBullet, then 'character'. 'character' (the default) draws the glyph marker. 'i... |\n\n\n### Theme\n\n- Type: `object`\n- Required fields: none\n- Purpose: Theme bundle used by the design system. In design.theme, 'id' resolves a themes catalog record as the base; any sibling fields override the resolved theme. The string shorthand on design.theme is equivalent to setting only 'id'.\n\n| Field | Required | Type | Notes |\n| --- | --- | --- | --- |\n| `id` | no | `string` | Theme reference. Resolves to the 'id' of a 'themes' catalog record. Accepts a bare id (lowercase kebab-case, e.g. 'minimal'), an HTTPS URL pointing at a record file, or a 'pkg:' reference. Field overrides on the surro... |\n| `name` | no | `string` | Human-readable theme name shown in pickers. |\n| `summary` | no | `string` | One-sentence positioning of the theme - when to reach for it. |\n| `description` | no | `string` | Longer prose describing what the theme looks and feels like and the kinds of decks it suits. |\n| `colorScheme` | no | `oneOf:string / ref:ColorScheme` | Default color scheme for this theme. A string resolves against catalogs.colorSchemes; an object may provide an 'id' base reference plus overrides. |\n| `fontScheme` | no | `oneOf:string / ref:FontScheme` | Default font scheme for this theme. A string resolves against catalogs.fontSchemes; an object may provide an 'id' base reference plus overrides. |\n| `background` | no | `oneOf:ref:BackgroundShortcut / ref:Background` | Default background for this theme. String shorthand accepts theme slots ('light1', 'light2', 'dark1', 'dark2') or hex colors. |\n| `dimensions` | no | `oneOf:ref:DimensionPreset / ref:Dimensions` | Default slide size for this theme. A string preset is equivalent to { preset: value }. |\n| `tags` | no | `array<string>` | Free-form labels for filtering and search. |\n\n\n### ColorScheme\n\n- Type: `object`\n- Required fields: none\n- Purpose: Color palette used by the design system. Every slot and role is a hex color ('#RGB', '#RRGGBB' or '#RRGGBBAA'); a color-scheme name or a 'var:<id>' reference is not valid here, because those resolve through the scheme. The slot fields (accent1-accent6, dark1, dark2, light1, light2, hyperlink, followedHyperlink) mirror color-scheme.schema.json (https://openpresentation.org/schema/opf-color-scheme/v1) so library records and inline OPF overrides are interchangeable on those fields. Two parallel...\n\n| Field | Required | Type | Notes |\n| --- | --- | --- | --- |\n| `id` | no | `string` | Color scheme reference. Resolves to the 'id' of a 'colorSchemes' catalog record. Accepts a bare id (lowercase kebab-case, e.g. 'cool-horizon'), an HTTPS URL pointing at a record file, or a 'pkg:' reference. Slot and r... |\n| `accent1` | no | `ref:HexColor` | Accent 1 color (hex). Mirrors the OOXML accent1 slot. |\n| `accent2` | no | `ref:HexColor` | Accent 2 color (hex). Mirrors the OOXML accent2 slot. |\n| `accent3` | no | `ref:HexColor` | Accent 3 color (hex). Mirrors the OOXML accent3 slot. |\n| `accent4` | no | `ref:HexColor` | Accent 4 color (hex). Mirrors the OOXML accent4 slot. |\n| `accent5` | no | `ref:HexColor` | Accent 5 color (hex). Mirrors the OOXML accent5 slot. |\n| `accent6` | no | `ref:HexColor` | Accent 6 color (hex). Mirrors the OOXML accent6 slot. |\n| `dark1` | no | `ref:HexColor` | Dark 1 color (hex). Typically the deepest neutral; OOXML dark1. |\n| `dark2` | no | `ref:HexColor` | Dark 2 color (hex). Secondary dark; OOXML dark2. |\n| `light1` | no | `ref:HexColor` | Light 1 color (hex). Typically the slide canvas; OOXML lt1. |\n| `light2` | no | `ref:HexColor` | Light 2 color (hex). Secondary light surface; OOXML lt2. |\n| `hyperlink` | no | `ref:HexColor` | Hyperlink color (hex). OOXML hlink. Link runs with no color of their own are drawn underlined in it, in the preview and in PowerPoint, unless it has under 4.5:1 contrast against the slide background, then in the slide... |\n| `followedHyperlink` | no | `ref:HexColor` | Followed-hyperlink color (hex). OOXML folHlink. |\n| `primary` | no | `ref:HexColor` | Abstract role: primary brand color (hex). The engine maps this onto an OOXML accent slot when serializing. |\n| `secondary` | no | `ref:HexColor` | Abstract role: secondary brand color (hex). |\n| `accent` | no | `ref:HexColor` | Abstract role: accent color used for highlights and emphasis (hex). |\n| `background` | no | `ref:HexColor` | Abstract role: default slide background color (hex), used when the design names no single-color background (and for gradient and picture backgrounds). Overrides light1 as that default. |\n| `surface` | no | `ref:HexColor` | Abstract role: color for elevated surfaces such as cards and panels (hex). Overrides the default of light2 (dark2 on a dark slide). |\n| `text` | no | `ref:HexColor` | Abstract role: primary body text color (hex). Overrides dark1 on a light slide only; a dark slide always uses light1. |\n| `textSecondary` | no | `ref:HexColor` | Abstract role: secondary or muted text color used for captions and supporting copy (hex). Overrides the default of dark2 (light2 on a dark slide). |\n\n\n### FontScheme\n\n- Type: `object`\n- Required fields: none\n- Purpose: Typography selections used by the design system. The pair fields (major, minor) and refinement fields (type, app, languageFamily) mirror font-scheme.schema.json (https://openpresentation.org/schema/opf-font-scheme/v1) so library records and inline OPF overrides are interchangeable on those fields. Two parallel models are supported and may be mixed: - OOXML pairs (major, minor) - heading and body family names that round-trip directly to PowerPoint majorFont/minorFont entries. - Abstract roles...\n\n| Field | Required | Type | Notes |\n| --- | --- | --- | --- |\n| `id` | no | `string` | Font scheme reference. Resolves to the 'id' of a 'fontSchemes' catalog record. Accepts a bare id (lowercase kebab-case, e.g. 'aptos'), an HTTPS URL pointing at a record file, or a 'pkg:' reference. Field overrides on... |\n| `major` | no | `string` | Heading (major) font family mirrors the OOXML majorFont entry. Pairs with 'minor'. |\n| `minor` | no | `string` | Body (minor) font family mirrors the OOXML minorFont entry. Pairs with 'major'. |\n| `eastAsian` | no | `object` | East Asian script fonts. Maps to the OOXML a:ea element of majorFont (major) and minorFont (minor), and to run-level a:ea. When set, they fill the eastAsian slot for every language; when omitted, the slot comes from t... |\n| `complexScript` | no | `object` | Complex-script fonts (for example Arabic, Hebrew, Indic and Thai). Maps to the OOXML a:cs element of majorFont (major) and minorFont (minor), and to run-level a:cs. When set, they fill the complexScript slot for every... |\n| `type` | no | `enum:sans-serif \\| serif \\| monospace` | High-level typographic class of the scheme. |\n| `app` | no | `enum:powerpoint \\| google-slides` | Target application this font pairing is intended for. Metadata for pickers and catalog filters: no engine changes its output by it. |\n| `languageFamily` | no | `enum:latin \\| ea \\| cs \\| eastAsian \\| complexScript` | Font-language family this scheme is intended for: 'latin' for Latin-script content, 'ea' (or 'eastAsian', the same value) for East Asian scripts, 'cs' (or 'complexScript', the same value) for Complex Scripts. The long... |\n| `heading` | no | `string` | Abstract role: font family name used for slide titles and headings. Maps onto the OOXML major slot when serializing. |\n| `body` | no | `string` | Abstract role: font family name used for body copy. Maps onto the OOXML minor slot when serializing. |\n| `accent` | no | `string` | Abstract role: font family name used for accent text. When set, the slide tag (eyebrow) and the quote body use this family instead of the body and heading families; nothing else changes. resolveFontFamilies() returns... |\n| `code` | no | `string` | Abstract role: monospaced font family name used for code blocks and for inline code runs (TextRun.code). No direct OOXML slot. Resolution: this override, then the resolved catalog record's 'code' (for example Consolas... |\n\n\n### SlideDesign\n\n- Type: `allOf:ref:Design + schema`\n- Required fields: none\n- Purpose: A slide's design: every Design field except dimensions. A PPTX has one slide size, so the size is set once, on the deck's design.dimensions (or its theme), and a slide's design cannot set it. A slide-level theme whose resolved dimensions differ from the deck's is a validate warning (opf/slide-theme-dimensions); exporting such a deck to PPTX fails with mixed-slide-dimensions.\n\n_No named properties._\n\n\n### DimensionPreset\n\n- Type: `enum:16:9 | 4:3 | 16:10 | 1:1 | 4:5 | 9:16 | letter | a4 | widescreen | standard`\n- Required fields: none\n- Purpose: Named dimension preset; chooses both aspect ratio and physical size. 'widescreen' is an alias for 16:9 in PowerPoint widescreen size; 'standard' is an alias for 4:3 in PowerPoint standard size. The social-feed ratios keep the widescreen short edge of 7.5 in: 1:1 is 7.5 x 7.5 in, 4:5 is 7.5 x 9.375 in and 9:16 is 7.5 x 13.333 in (portrait).\n\n_No named properties._\n\n\n### Dimensions\n\n- Type: `object`\n- Required fields: none\n- Purpose: Slide dimensions; either pick a preset or specify custom inches.\n\n| Field | Required | Type | Notes |\n| --- | --- | --- | --- |\n| `preset` | no | `ref:DimensionPreset` | |\n| `widthInches` | no | `number` | Custom slide width in inches; overrides the preset width when provided. |\n| `heightInches` | no | `number` | Custom slide height in inches; overrides the preset height when provided. |\n\n\n### ThemeBackgroundSlot\n\n- Type: `enum:light1 | light2 | dark1 | dark2`\n- Required fields: none\n- Purpose: PowerPoint theme-controlled slide background slot from the active color scheme. These are slots, not assumptions about actual colors: light1 is usually white and dark1 is usually black by convention, but the color scheme controls the real values.\n\n_No named properties._\n\n\n### HexColor\n\n- Type: `string`\n- Required fields: none\n- Purpose: Hex color shorthand accepted by selected string fields.\n\n_No named properties._\n\n\n### ColorRef\n\n- Type: `anyOf:ref:HexColor / enum:accent1 | accent2 | accent3 | accent4 | accent5 | accent6 | dark1 | dark2 | light1 | light2 | hyperlink | followedHyperlink | primary | secondary | accent | background | surface | surfaceAlt | text | textSecondary / string`\n- Required fields: none\n- Purpose: A color value or reference, enforced on styled table cell fill and text colors, cell border colors, solid background colors, gradient stop colors and pattern foreground and background colors. Three forms: - Literal hex: '#RGB', '#RRGGBB', or '#RRGGBBAA'. - Color-scheme name, resolved through the effective color scheme after design resolution: an OOXML slot ('accent1'-'accent6', 'dark1', 'dark2', 'light1', 'light2', 'hyperlink', 'followedHyperlink') or an abstract role ('primary', 'secondary',...\n\n_No named properties._\n\n\n### Variables\n\n- Type: `object`\n- Required fields: none\n- Purpose: Named variables, keyed by stable kebab-case id. A variable is a typed, named value the deck declares once and uses in many places: a color ('var:<id>' in color fields, the original use), or content that fills a template (text, number, date, image, url, list). Content is referenced inline as '{{<id>}}' inside any string, or whole as 'var:<id>' in a field of the matching type; '\\{{' writes a literal '{{'. A hex string is shorthand for a color variable. A variable with no 'value' is unfilled: ex...\n\n_No named properties._\n\n\n### Variable\n\n- Type: `oneOf:ref:HexColor / ref:ColorVariable / ref:TextVariable / ref:NumberVariable / ref:DateVariable / ref:ImageVariable / ref:UrlVariable / ref:ListVariable`\n- Required fields: none\n- Purpose: A single named variable: a hex string (shorthand for a color variable) or an object whose 'type' is color, text, number, date, image, url or list. 'value' is the current value and is optional: a variable with no value is unfilled, which a template allows and a normal deck does not. 'example' only illustrates the slot (fill forms, template previews) and never reaches output.\n\n_No named properties._\n\n\n### ColorVariable\n\n- Type: `object`\n- Required fields: `type`\n- Purpose: A named color. Content color fields reference it as 'var:<id>'. Colors resolve at render time through the ordinary color-reference path, so a color variable keeps working as before.\n\n| Field | Required | Type | Notes |\n| --- | --- | --- | --- |\n| `type` | yes | `const:\"color\"` | Variable kind. One of color, text, number, date, image, url or list. |\n| `value` | no | `ref:HexColor` | Hex color this variable resolves to. |\n| `required` | no | `boolean` | Whether the variable must be filled. Defaults to true: a variable with no 'value' is unfilled, and an unfilled required variable is an error in a normal deck and expected in a template. Set false for an optional slot:... |\n| `label` | no | `string` | Optional short human label for forms and fill panels. |\n| `description` | no | `string` | Optional prose describing what the variable is for, surfaced by pickers, fill forms and agents. |\n| `example` | no | `ref:HexColor` | Illustrative color shown in fill forms and used when a template is previewed with examples. Never written to output. |\n\n\n### TextVariable\n\n- Type: `object`\n- Required fields: `type`\n- Purpose: Text content. Plain string or rich TextRun[]. Insert it inline as '{{<id>}}' inside any string (rich text is flattened to plain text there), or reference it whole as 'var:<id>' in a field that accepts string or TextRun[] (the rich runs are kept).\n\n| Field | Required | Type | Notes |\n| --- | --- | --- | --- |\n| `type` | yes | `const:\"text\"` | Variable kind. One of color, text, number, date, image, url or list. |\n| `value` | no | `oneOf:string / array<ref:TextRun>` | Text this variable resolves to: a plain string, or TextRun[] for rich text. |\n| `required` | no | `boolean` | Whether the variable must be filled. Defaults to true: a variable with no 'value' is unfilled, and an unfilled required variable is an error in a normal deck and expected in a template. Set false for an optional slot:... |\n| `label` | no | `string` | Optional short human label for forms and fill panels. |\n| `description` | no | `string` | Optional prose describing what the variable is for, surfaced by pickers, fill forms and agents. |\n| `example` | no | `oneOf:string / array<ref:TextRun>` | Illustrative text shown in fill forms and used when a template is previewed with examples. Never written to output. |\n\n\n### NumberVariable\n\n- Type: `object`\n- Required fields: `type`\n- Purpose: A number. '{{<id>}}' inserts it as text using 'format'; 'var:<id>' as a whole field supplies the number itself (chart values, font sizes).\n\n| Field | Required | Type | Notes |\n| --- | --- | --- | --- |\n| `type` | yes | `const:\"number\"` | Variable kind. One of color, text, number, date, image, url or list. |\n| `value` | no | `number` | Number this variable resolves to. |\n| `required` | no | `boolean` | Whether the variable must be filled. Defaults to true: a variable with no 'value' is unfilled, and an unfilled required variable is an error in a normal deck and expected in a template. Set false for an optional slot:... |\n| `label` | no | `string` | Optional short human label for forms and fill panels. |\n| `description` | no | `string` | Optional prose describing what the variable is for, surfaced by pickers, fill forms and agents. |\n| `example` | no | `number` | Illustrative number shown in fill forms and used when a template is previewed with examples. Never written to output. |\n| `format` | no | `ref:NumberFormat` | Display pattern used by '{{<id>}}' (the shared NumberFormat syntax). A literal prefix, a numeric part of '#', '0', ',' and '.', and a literal suffix. '0' pads digits, '#' is optional, ',' groups thousands, digits afte... |\n\n\n### NumberFormat\n\n- Type: `string`\n- Required fields: none\n- Purpose: Number display pattern, shared by NumberVariable.format, data column formats (DataColumn.format) and table cell formats (StyledTableCell.format). An optional literal prefix, a numeric part of '#', '0', ',' and '.', and an optional literal suffix. '0' pads digits, '#' is optional, ',' groups thousands, digits after '.' fix the decimals ('0' required, '#' optional), and a '%' in the prefix or suffix multiplies the value by 100. English separators only. A format applies only to number values; st...\n\n_No named properties._\n\n\n### DateVariable\n\n- Type: `object`\n- Required fields: `type`\n- Purpose: A calendar date as an ISO YYYY-MM-DD string. No time zone and no clock are involved. '{{<id>}}' inserts it formatted with 'format'.\n\n| Field | Required | Type | Notes |\n| --- | --- | --- | --- |\n| `type` | yes | `const:\"date\"` | Variable kind. One of color, text, number, date, image, url or list. |\n| `value` | no | `string` | ISO calendar date (YYYY-MM-DD) this variable resolves to. |\n| `required` | no | `boolean` | Whether the variable must be filled. Defaults to true: a variable with no 'value' is unfilled, and an unfilled required variable is an error in a normal deck and expected in a template. Set false for an optional slot:... |\n| `label` | no | `string` | Optional short human label for forms and fill panels. |\n| `description` | no | `string` | Optional prose describing what the variable is for, surfaced by pickers, fill forms and agents. |\n| `example` | no | `string` | Illustrative ISO date shown in fill forms and used when a template is previewed with examples. Never written to output. |\n| `format` | no | `string` | Date display pattern, the same LDML-style tokens as header/footer dateFormat: yyyy, yy, MMMM, MMM, MM, M, dd, d, EEEE, EEE and quoted literals. English names. Default 'MMMM d, yyyy'. |\n\n\n### ImageVariable\n\n- Type: `object`\n- Required fields: `type`\n- Purpose: An image source: any Asset (an 'asset:<id>' reference, HTTPS URL, data URI, relative or local path, or an object with src, alt and metadata). Reference it whole as 'var:<id>' in an image, asset or src field; '{{<id>}}' inserts the source string.\n\n| Field | Required | Type | Notes |\n| --- | --- | --- | --- |\n| `type` | yes | `const:\"image\"` | Variable kind. One of color, text, number, date, image, url or list. |\n| `value` | no | `ref:Asset` | Image source this variable resolves to. |\n| `required` | no | `boolean` | Whether the variable must be filled. Defaults to true: a variable with no 'value' is unfilled, and an unfilled required variable is an error in a normal deck and expected in a template. Set false for an optional slot:... |\n| `label` | no | `string` | Optional short human label for forms and fill panels. |\n| `description` | no | `string` | Optional prose describing what the variable is for, surfaced by pickers, fill forms and agents. |\n| `example` | no | `ref:Asset` | Illustrative image source shown in fill forms and used when a template is previewed with examples. Never written to output. |\n\n\n### UrlVariable\n\n- Type: `object`\n- Required fields: `type`\n- Purpose: A link target (http, https, mailto or tel). Use it as '{{<id>}}' inside a link string or reference it whole as 'var:<id>' in a link field.\n\n| Field | Required | Type | Notes |\n| --- | --- | --- | --- |\n| `type` | yes | `const:\"url\"` | Variable kind. One of color, text, number, date, image, url or list. |\n| `value` | no | `string` | Link target this variable resolves to. |\n| `required` | no | `boolean` | Whether the variable must be filled. Defaults to true: a variable with no 'value' is unfilled, and an unfilled required variable is an error in a normal deck and expected in a template. Set false for an optional slot:... |\n| `label` | no | `string` | Optional short human label for forms and fill panels. |\n| `description` | no | `string` | Optional prose describing what the variable is for, surfaced by pickers, fill forms and agents. |\n| `example` | no | `string` | Illustrative link shown in fill forms and used when a template is previewed with examples. Never written to output. |\n\n\n### ListVariable\n\n- Type: `object`\n- Required fields: `type`\n- Purpose: A list of strings, for bullets and list items. A whole-string array element 'var:<id>' splices every entry into the array in place; a whole field 'var:<id>' becomes the array; '{{<id>}}' joins the entries with ', ' (or the separator after a pipe: '{{<id>|; }}').\n\n| Field | Required | Type | Notes |\n| --- | --- | --- | --- |\n| `type` | yes | `const:\"list\"` | Variable kind. One of color, text, number, date, image, url or list. |\n| `value` | no | `array<string>` | Entries this variable resolves to. |\n| `required` | no | `boolean` | Whether the variable must be filled. Defaults to true: a variable with no 'value' is unfilled, and an unfilled required variable is an error in a normal deck and expected in a template. Set false for an optional slot:... |\n| `label` | no | `string` | Optional short human label for forms and fill panels. |\n| `description` | no | `string` | Optional prose describing what the variable is for, surfaced by pickers, fill forms and agents. |\n| `example` | no | `array<string>` | Illustrative entries shown in fill forms and used when a template is previewed with examples. Never written to output. |\n\n\n### BackgroundShortcut\n\n- Type: `oneOf:ref:ThemeBackgroundSlot / ref:HexColor`\n- Required fields: none\n- Purpose: String shorthand for a background. Theme slots ('light1', 'light2', 'dark1', 'dark2') are equivalent to { type: 'theme', slot: value }; hex colors are equivalent to { type: 'solid', color: value }.\n\n_No named properties._\n\n\n### Background\n\n- Type: `oneOf:ref:ThemeBackground / ref:SolidBackground / ref:GradientBackground / ref:ImageBackground / ref:PatternBackground`\n- Required fields: none\n- Purpose: Background fill applied to slides. Theme backgrounds preserve PowerPoint's color-scheme background choice; other variants represent fixed background fills.\n\n_No named properties._\n\n\n### ThemeBackground\n\n- Type: `object`\n- Required fields: `type`, `slot`\n- Purpose: Theme-controlled PowerPoint slide background. The slot is resolved through the active color scheme and remains theme-aware.\n\n| Field | Required | Type | Notes |\n| --- | --- | --- | --- |\n| `type` | yes | `const:\"theme\"` | Theme-controlled background fill. |\n| `slot` | yes | `ref:ThemeBackgroundSlot` | |\n\n\n### SolidBackground\n\n- Type: `object`\n- Required fields: `type`, `color`\n- Purpose: Fixed solid slide background fill.\n\n| Field | Required | Type | Notes |\n| --- | --- | --- | --- |\n| `type` | yes | `const:\"solid\"` | Fixed solid background fill. |\n| `color` | yes | `ref:ColorRef` | Fixed solid fill color: a hex string, a color-scheme slot or role name, or a var:<id> variable reference (a ColorRef, resolved against the effective color scheme and the deck variables). Use { type: 'theme', slot: ...... |\n| `opacity` | no | `number` | Background opacity from 0 (fully transparent) to 1 (fully opaque). |\n\n\n### GradientBackground\n\n- Type: `object`\n- Required fields: `type`, `gradient`\n- Purpose: Fixed gradient slide background fill.\n\n| Field | Required | Type | Notes |\n| --- | --- | --- | --- |\n| `type` | yes | `const:\"gradient\"` | Fixed gradient background fill. |\n| `gradient` | yes | `object` | Gradient fill definition. |\n| `opacity` | no | `number` | Background opacity from 0 (fully transparent) to 1 (fully opaque). |\n\n\n### ImageBackground\n\n- Type: `object`\n- Required fields: `type`, `image`\n- Purpose: Fixed image slide background fill.\n\n| Field | Required | Type | Notes |\n| --- | --- | --- | --- |\n| `type` | yes | `const:\"image\"` | Fixed image background fill. |\n| `image` | yes | `object` | Image fill definition. |\n| `opacity` | no | `number` | Background opacity from 0 (fully transparent) to 1 (fully opaque). |\n\n\n### PatternBackground\n\n- Type: `object`\n- Required fields: `type`, `pattern`\n- Purpose: Fixed pattern slide background fill.\n\n| Field | Required | Type | Notes |\n| --- | --- | --- | --- |\n| `type` | yes | `const:\"pattern\"` | Fixed pattern background fill. |\n| `pattern` | yes | `object` | Pattern fill definition. |\n| `opacity` | no | `number` | Background opacity from 0 (fully transparent) to 1 (fully opaque). |\n\n\n### LogoSet\n\n- Type: `object`\n- Required fields: none\n- Purpose: Deck logo variants surfaced by covers, section dividers, headers, footers and picture bullets. Organization identity lives in organization; this object only controls visual rendering assets. Engines select one variant per slot and background tone (resolveLogo in @openpresentation/opf): same-tone variants first, neutral ones next, the opposite tone last. Lockup on a dark background: light, default, stackedLight, stacked, wordmarkLight, wordmark, iconLight, icon, then dark, stackedDark, wordmar...\n\n| Field | Required | Type | Notes |\n| --- | --- | --- | --- |\n| `default` | no | `ref:Asset` | Default full-lockup logo. Used as fallback when no more specific variant is set. |\n| `light` | no | `ref:Asset` | Light-colored full-lockup logo intended for rendering on dark backgrounds. |\n| `dark` | no | `ref:Asset` | Dark-colored full-lockup logo intended for rendering on light backgrounds. |\n| `stacked` | no | `ref:Asset` | Stacked vertical logo lockup, suited to portrait or square brand-mark slots. |\n| `stackedLight` | no | `ref:Asset` | Light-colored stacked logo variant intended for rendering on dark backgrounds. |\n| `stackedDark` | no | `ref:Asset` | Dark-colored stacked logo variant intended for rendering on light backgrounds. |\n| `icon` | no | `ref:Asset` | Default icon, mark, or symbol without wordmark. Useful for tight spaces such as footers, badges, and slide-corner marks. |\n| `iconLight` | no | `ref:Asset` | Light-colored icon variant intended for rendering on dark backgrounds. |\n| `iconDark` | no | `ref:Asset` | Dark-colored icon variant intended for rendering on light backgrounds. |\n| `wordmark` | no | `ref:Asset` | Default wordmark: the organization name set in branded typography, without icon. |\n| `wordmarkLight` | no | `ref:Asset` | Light-colored wordmark variant intended for rendering on dark backgrounds. |\n| `wordmarkDark` | no | `ref:Asset` | Dark-colored wordmark variant intended for rendering on light backgrounds. |\n\n\n### Watermark\n\n- Type: `oneOf:schema / schema`\n- Required fields: `opacity`\n- Purpose: Decorative watermark: an image ('src') or a text stamp ('text'), exactly one of the two, with its opacity. An image is drawn once per slide, contained and centered in a fixed frame (the middle 40% of the slide width and height, from 30% to 70% on each axis). A text watermark is one line of text in the heading font and the theme text color at the given opacity, centered on the slide and rotated 30 degrees counterclockwise (rising to the right), sized to span at most 70% of the slide width and...\n\n| Field | Required | Type | Notes |\n| --- | --- | --- | --- |\n| `src` | no | `string` | Source for the watermark image: an 'asset:<id>' reference, HTTPS URL, data URI, relative path or local path, as for Asset.src. Exactly one of src and text is set. |\n| `text` | no | `string` | Text for a text watermark, such as DRAFT or CONFIDENTIAL; line breaks are drawn as spaces. Exactly one of src and text is set. |\n| `opacity` | yes | `number` | Watermark opacity from 0 (fully transparent) to 1 (fully opaque). |\n\n\n### HeaderFooter\n\n- Type: `object`\n- Required fields: none\n- Purpose: Repeated header or footer content split into left, center, and right zones. Header/footer content is slide furniture, separate from the main slide content payloads.\n\n| Field | Required | Type | Notes |\n| --- | --- | --- | --- |\n| `left` | no | `ref:HeaderFooterItem` | Left-aligned header/footer content. |\n| `center` | no | `ref:HeaderFooterItem` | Centered header/footer content. |\n| `right` | no | `ref:HeaderFooterItem` | Right-aligned header/footer content. |\n\n\n### HeaderFooterItem\n\n- Type: `object`\n- Required fields: none\n- Purpose: One header/footer zone. Every configured field renders; fields in one zone stack top to bottom in the order logo, image, text, organization, speaker, socials, section, slide number, date. Put a date and a slide number in different zones to keep each on the zone's single line.\n\n| Field | Required | Type | Notes |\n| --- | --- | --- | --- |\n| `logo` | no | `boolean` | Whether to render the deck's icon logo in this zone: a slide's design.logo, then design.logo, then the primary organization's logo (LogoSet icon variants first, light ones on dark backgrounds). It is a generated image... |\n| `text` | no | `string` | Literal text rendered in this zone. |\n| `image` | no | `ref:Asset` | Generic image rendered in this zone, such as a logo, partner mark, certification badge, or icon. |\n| `slideNumber` | no | `boolean` | Whether to render the current slide number in this zone. PPTX export writes a native slide-number field when its value fits within one accepted text line; a value split across lines exports as static text with a diagn... |\n| `slideNumberFormat` | no | `string` | Template for the slide number when slideNumber is true. {current} is the displayed slide number (a native PPTX field when its value fits within one accepted text line); {total} is the number of slides in the rendered... |\n| `date` | no | `oneOf:boolean / string` | true renders the current date: the renderer or exporter must be given an explicit ISO date by its host (core never reads a clock). PPTX export writes a native date field only for a supported dateFormat whose complete... |\n| `dateFormat` | no | `string` | Date pattern for date. Tokens: yyyy (2026), yy (26), MMMM (April), MMM (Apr), MM (04), M (4), dd (09), d (9), EEEE (Thursday), EEE (Thu). Text in single quotes and other non-letter characters are literal. Month and we... |\n| `organization` | no | `boolean` | Whether to render the primary organization's name from organization (role 'primary', else the first organization). |\n| `speaker` | no | `boolean` | Whether to render the primary (first) speaker's name and title from speaker, joined as 'Ada Lovelace, CTO' (just the name when the speaker has no title). It is generated text: without a named speaker the engine report... |\n| `section` | no | `boolean` | Whether to render the current slide section label. |\n| `socials` | no | `boolean` | Whether to render the primary organization's social profiles from organization.socials, one line per platform in key order. A handle is formatted through the platform's socialPlatforms record (companyUrlPattern, else... |\n\n\n### Slide\n\n- Type: `object`\n- Required fields: none\n- Purpose: A single slide. Content can be authored as a full-slide root payload, or inside promoted named region keys such as 'left', 'center+right', and 'top:left'.\n\n| Field | Required | Type | Notes |\n| --- | --- | --- | --- |\n| `id` | no | `string` | Optional stable identifier for the slide within the document. Use when another system needs to reference a slide across edits, comments, generation state, exports, or narrative tooling. Slide order is defined by the s... |\n| `type` | no | `enum:text \\| list \\| image \\| chart \\| table \\| video \\| code \\| metric \\| quote \\| timeline` | Optional full-slide content kind. When omitted, engines infer the kind from root payload fields. |\n| `beat` | no | `oneOf:string / array<string>` | Optional reference to one or more beats of the deck's narrative (each value is the id of a beat in the narrative record that the root 'narrative' resolves to). A single string declares the slide's primary beat; an arr... |\n| `layout` | no | `string` | Optional. Resolves to the 'id' of a 'layouts' catalog record. When omitted, or when it names an id that no inline record, host catalog or default catalog defines, the slide is composed with no layout record: engines a... |\n| `title` | no | `oneOf:string / array<ref:TextRun>` | Slide-level title content. When the resolved layout exposes a 'title' placeholder, the engine renders this value there. Use a string for plain text or TextRun[] for inline rich text (a colored word, bold, a link, a ci... |\n| `subtitle` | no | `oneOf:string / array<ref:TextRun>` | Slide-level subtitle or supporting line. When the resolved layout exposes a 'subtitle' placeholder, the engine renders this value there. Use a string for plain text or TextRun[] for inline rich text (a colored word, b... |\n| `tag` | no | `oneOf:string / array<ref:TextRun>` | Small slide-level label or badge. When the resolved layout exposes a 'tag' placeholder, the engine renders this value there. Use a string for plain text or TextRun[] for inline rich text (a colored word, bold, a link,... |\n| `text` | no | `oneOf:string / array<ref:TextRun>` | Full-slide text payload. Use a string for plain text or TextRun[] for inline rich text. TextRun items may be plain strings or formatted run objects. |\n| `items` | no | `array<ref:ListItem>` | Full-slide generic list payload. Presence of this field infers type 'list'. At slide root, multiple content payload kinds with no explicit type, blocks, or regions are accepted as shorthand for layout-agnostic blocks. |\n| `bullets` | no | `array<ref:BulletItem>` | Full-slide text-style bullet payload. Presence of this field infers type 'text'. |\n| `numbering` | no | `ref:NumberingSpec` | Number the full-slide `items` or `bullets` instead of bulleting them. A style name (arabic, roman-upper, roman-lower, alpha-upper, alpha-lower) or a Numbering object applies to every list level; an array gives one ent... |\n| `image` | no | `ref:Asset` | Full-slide image source. Presence of this field infers type 'image'. |\n| `video` | no | `ref:Asset` | Full-slide video source. Presence of this field infers type 'video'. Engines do not play video: the preview draws a placeholder (a play badge with the asset's title or source as its caption), and PPTX export draws the... |\n| `chart` | no | `ref:Chart` | Full-slide chart payload. Presence of this field infers type 'chart'. |\n| `table` | no | `ref:Table` | Full-slide table payload. Presence of this field infers type 'table'. |\n| `code` | no | `oneOf:string / ref:Code` | Full-slide code payload. A string is shorthand for { \"source\": value }; object form carries optional syntax language and filename metadata. |\n| `metric` | no | `oneOf:string / number / ref:Metric` | Full-slide metric payload. A string or number is shorthand for { \"value\": value }; object form carries optional label, description, unit, delta, trend, and sentiment metadata. Numeric values remain numbers; renderers... |\n| `quote` | no | `oneOf:string / ref:Quote` | Full-slide quote payload. A string is shorthand for { \"text\": value }; object form carries optional attribution and source metadata. Presence of this field infers type 'quote'. |\n| `timeline` | no | `ref:Timeline` | Full-slide timeline payload. An array is shorthand for { \"events\": value }; object form carries optional name and description metadata. Presence of this field infers type 'timeline'. |\n| `caption` | no | `ref:Caption` | Caption for the slide's root image, chart, table or video payload. Valid only when the slide root holds exactly one of those payloads. |\n| `blocks` | no | `array<ref:ContentPayload>` | Layout-agnostic content blocks rendered together as a composed payload when exact placement is unspecified. At slide root, multiple content payload kinds with no explicit type, blocks, or regions are accepted as short... |\n| `design` | no | `ref:SlideDesign` | Slide-level design applied on top of the deck-wide design. |\n| `left` | no | `ref:ContentPayload` | |\n| `center` | no | `ref:ContentPayload` | |\n| `right` | no | `ref:ContentPayload` | |\n| `left+center` | no | `ref:ContentPayload` | |\n| `center+right` | no | `ref:ContentPayload` | |\n| `left+center+right` | no | `ref:ContentPayload` | |\n| `top` | no | `ref:ContentPayload` | |\n| `middle` | no | `ref:ContentPayload` | |\n| `bottom` | no | `ref:ContentPayload` | |\n| `top+middle` | no | `ref:ContentPayload` | |\n| `middle+bottom` | no | `ref:ContentPayload` | |\n| `top+middle+bottom` | no | `ref:ContentPayload` | |\n| `top:left` | no | `ref:ContentPayload` | |\n| `top:center` | no | `ref:ContentPayload` | |\n| `top:right` | no | `ref:ContentPayload` | |\n| `top:left+center` | no | `ref:ContentPayload` | |\n| `top:center+right` | no | `ref:ContentPayload` | |\n| `top:left+center+right` | no | `ref:ContentPayload` | |\n| `middle:left` | no | `ref:ContentPayload` | |\n| `middle:center` | no | `ref:ContentPayload` | |\n| `middle:right` | no | `ref:ContentPayload` | |\n| `middle:left+center` | no | `ref:ContentPayload` | |\n| `middle:center+right` | no | `ref:ContentPayload` | |\n| `middle:left+center+right` | no | `ref:ContentPayload` | |\n| `bottom:left` | no | `ref:ContentPayload` | |\n| `bottom:center` | no | `ref:ContentPayload` | |\n| `bottom:right` | no | `ref:ContentPayload` | |\n| `bottom:left+center` | no | `ref:ContentPayload` | |\n| `bottom:center+right` | no | `ref:ContentPayload` | |\n| `bottom:left+center+right` | no | `ref:ContentPayload` | |\n| `top+middle:left` | no | `ref:ContentPayload` | |\n| `top+middle:center` | no | `ref:ContentPayload` | |\n| `top+middle:right` | no | `ref:ContentPayload` | |\n| `top+middle:left+center` | no | `ref:ContentPayload` | |\n| `top+middle:center+right` | no | `ref:ContentPayload` | |\n| `top+middle:left+center+right` | no | `ref:ContentPayload` | |\n| `middle+bottom:left` | no | `ref:ContentPayload` | |\n| `middle+bottom:center` | no | `ref:ContentPayload` | |\n| `middle+bottom:right` | no | `ref:ContentPayload` | |\n| `middle+bottom:left+center` | no | `ref:ContentPayload` | |\n| `middle+bottom:center+right` | no | `ref:ContentPayload` | |\n| `middle+bottom:left+center+right` | no | `ref:ContentPayload` | |\n| `top+middle+bottom:left` | no | `ref:ContentPayload` | |\n| `top+middle+bottom:center` | no | `ref:ContentPayload` | |\n| `top+middle+bottom:right` | no | `ref:ContentPayload` | |\n| `top+middle+bottom:left+center` | no | `ref:ContentPayload` | |\n| `top+middle+bottom:center+right` | no | `ref:ContentPayload` | |\n| `top+middle+bottom:left+center+right` | no | `ref:ContentPayload` | |\n| `notes` | no | `string` | Speaker notes shown in presenter view. |\n| `section` | no | `string` | PowerPoint-style slide section label. Consecutive slides with the same value belong to the same section in presenter view, outlines, and PowerPoint section-aware exports. |\n| `hidden` | no | `boolean` | Whether the slide is hidden from the presented sequence. The player skips it, the PPTX export writes it as a hidden slide, and per-slide image and PDF output skips it unless the caller asks to include hidden slides (o... |\n| `composition` | no | `ref:Composition` | |\n| `extensions` | no | `object` | Custom data passthrough for agent workflows at slide scope; ignored by the engine but preserved across read/write round-trips. Use for review state, generation provenance, or authoring conventions such as { \"authoring... |\n\n\n### ContentPayload\n\n- Type: `allOf:schema + schema + schema + schema + schema + schema + schema + schema + schema + schema + schema + schema`\n- Required fields: none\n- Purpose: A content leaf or recursively composed group. A group contains blocks and optional composition; it cannot mix blocks with leaf payload fields. Groups may nest up to 32 levels.\n\n| Field | Required | Type | Notes |\n| --- | --- | --- | --- |\n| `id` | no | `string` | Optional stable identifier for this payload, unique among slide and payload ids in the document. Use when another system needs to address the payload across edits patch-style agent edits, comments, review state, or ge... |\n| `extensions` | no | `object` | Custom data passthrough for agent workflows at payload scope; ignored by the engine but preserved across read/write round-trips. |\n| `type` | no | `enum:text \\| list \\| image \\| chart \\| table \\| video \\| code \\| metric \\| quote \\| timeline \\| group` | Optional content kind. When omitted, engines infer the kind from the fields present. |\n| `text` | no | `oneOf:string / array<ref:TextRun>` | Text payload. Use a string for plain text or TextRun[] for inline rich text. TextRun items may be plain strings or formatted run objects. |\n| `items` | no | `array<ref:ListItem>` | Generic list payload. Each item is either a plain string, a TextRun[] rich text sequence, or a ListItem object. List nesting uses item.level rather than nested content payloads. |\n| `bullets` | no | `array<ref:BulletItem>` | Text-style bullet payload. Presence of this field infers type 'text'. |\n| `numbering` | no | `ref:NumberingSpec` | Number the payload's `items` or `bullets` instead of bulleting them. A style name (arabic, roman-upper, roman-lower, alpha-upper, alpha-lower) or a Numbering object applies to every list level; an array gives one entr... |\n| `image` | no | `ref:Asset` | Source for an image item. |\n| `video` | no | `ref:Asset` | Source for a video item. Engines do not play video: the preview draws a placeholder (a play badge with the asset's title or source as its caption), and PPTX export draws the same placeholder as native shapes linked to... |\n| `chart` | no | `ref:Chart` | Chart payload. Presence of this field infers type 'chart'. |\n| `table` | no | `ref:Table` | Table payload. Presence of this field infers type 'table'. |\n| `code` | no | `oneOf:string / ref:Code` | Code payload. A string is shorthand for { \"source\": value }; object form carries optional syntax language and filename metadata. |\n| `metric` | no | `oneOf:string / number / ref:Metric` | Metric payload. A string or number is shorthand for { \"value\": value }; object form carries optional label, description, unit, delta, trend, and sentiment metadata. Numeric values remain numbers; renderers format them... |\n| `quote` | no | `oneOf:string / ref:Quote` | Quote payload. A string is shorthand for { \"text\": value }; object form carries optional attribution, role, photo and source metadata. |\n| `timeline` | no | `ref:Timeline` | Timeline payload ordered by narrative or chronology. |\n| `caption` | no | `ref:Caption` | Caption for an image, chart, table or video payload, composed inside the block's region (below the media by default). Invalid on other payload kinds and on groups. |\n| `blocks` | no | `array<ref:ContentPayload>` | Ordered children of a group. Each child is a leaf or another group. |\n| `composition` | no | `ref:Composition` | Arrangement within this group. Only minFontSize and overflow inherit from the parent; strict overflow cannot be weakened. |\n\n\n### Quote\n\n- Type: `object`\n- Required fields: `text`\n- Purpose: Quote content with optional attribution metadata. Use 'text' for the quoted text, 'attribution' for the credited person or organization, 'role' for that person's title and organization, 'photo' for their headshot and 'source' for a citation or URL. A string value in a quote field is shorthand for { \"text\": value }.\n\n| Field | Required | Type | Notes |\n| --- | --- | --- | --- |\n| `text` | yes | `oneOf:string / array<ref:TextRun>` | Quoted text: a string, or TextRun[] for inline rich text (a colored phrase, bold, a link, or a citation or footnote marker). The engine draws the surrounding quotation marks. |\n| `attribution` | no | `string` | Person or organization credited for the quote. Drawn after the quote text, in the muted text color. |\n| `role` | no | `string` | Title and organization of the attributed person. Drawn on its own line under the attribution; without an attribution it stands alone. It does not replace the attribution, so the person's name belongs in 'attribution'. |\n| `photo` | no | `ref:Asset` | Headshot of the attributed person, drawn as a circle beside the attribution and role lines (on the end side in a right-to-left deck). Give it alt text (validate's opf/missing-alt-text rule checks it). A raster photogr... |\n| `source` | no | `string` | Optional quote source, citation, or URL. Follows the attribution and role after ' - '. |\n\n\n### Code\n\n- Type: `object`\n- Required fields: `source`\n- Purpose: Code content with optional rendering metadata. Use 'source' for the code text, 'language' for syntax highlighting, 'filename' when the rendered block should show a file label, and 'highlight' to emphasize lines. A string value in a code field is shorthand for { \"source\": value }.\n\n| Field | Required | Type | Notes |\n| --- | --- | --- | --- |\n| `source` | yes | `string` | Source code text to display. |\n| `language` | no | `string` | Language identifier used for syntax highlighting. |\n| `filename` | no | `string` | Optional file label shown with the code block. |\n| `highlight` | no | `array<oneOf:integer / array<integer>>` | Source lines to emphasize, 1-based: each entry is a line number or an inclusive [start, end] range, so [3, [5, 7]] marks lines 3 and 5 to 7. Lines are counted by line break in 'source', so a line that wraps stays one... |\n\n\n### Metric\n\n- Type: `object`\n- Required fields: `value`\n- Purpose: Metric content with optional display metadata. Use 'value' for the primary value, 'label' for the metric name, 'description' for supporting context, 'unit' for a suffix/currency marker, 'delta' for change, 'trend' for direction, and 'sentiment' for whether the change is good news. A string or number value in a metric field is shorthand for { \"value\": value }; numeric values remain numbers and are formatted by renderers.\n\n| Field | Required | Type | Notes |\n| --- | --- | --- | --- |\n| `value` | yes | `oneOf:string / number` | Primary metric value. |\n| `label` | no | `string` | Metric label. |\n| `description` | no | `string` | Optional supporting context for the metric. |\n| `unit` | no | `string` | Metric unit, suffix, or currency marker. |\n| `delta` | no | `oneOf:string / number` | Metric change value. |\n| `trend` | no | `enum:up \\| down \\| flat` | Metric trend direction. The preview and the PowerPoint export draw an arrow beside the trend word that always points the way the trend does (up, down or flat) and colour the arrow, the trend word and the delta text by... |\n| `sentiment` | no | `enum:positive \\| negative \\| neutral` | Whether the change is good news. Positive draws the trend arrow and the trend and delta text in green, negative in red and neutral in the neutral text colour, each kept at 4.5:1 contrast or more against the slide back... |\n\n\n### Timeline\n\n- Type: `oneOf:array<ref:TimelineEvent> / object`\n- Required fields: none\n- Purpose: Timeline content. An array is shorthand for { \"events\": value }; object form carries optional name and description metadata.\n\n_No named properties._\n\n\n### TimelineEvent\n\n- Type: `object`\n- Required fields: `what`\n- Purpose: A single event inside a timeline content payload.\n\n| Field | Required | Type | Notes |\n| --- | --- | --- | --- |\n| `when` | no | `string` | Event time, date, or sequence label. Use ISO-like values when possible, but human labels are allowed for quarters, eras, and relative milestones. |\n| `what` | yes | `string` | Short event label. |\n| `description` | no | `string` | Optional event detail. |\n| `status` | no | `enum:done \\| current \\| planned` | Progress of the event, drawn from the deck's own colors by both the SVG renderer and the PPTX exporter. Absent means no status and the event is drawn as a plain filled marker with normal text, which is also how 'done'... |\n\n\n### ListItem\n\n- Type: `oneOf:string / array<ref:TextRun> / object`\n- Required fields: none\n- Purpose: A flat item inside a list. Strings cover the common case, TextRun[] supports inline rich text without an object wrapper, and object form adds description and nesting depth without creating nested slide content payloads.\n\n_No named properties._\n\n\n### BulletItem\n\n- Type: `oneOf:string / array<ref:TextRun> / object`\n- Required fields: none\n- Purpose: A flat bullet item. Strings cover the common case, TextRun[] supports inline rich text without an object wrapper, and object form adds nesting depth without list-item descriptions.\n\n_No named properties._\n\n\n### NumberingStyle\n\n- Type: `enum:arabic | roman-upper | roman-lower | alpha-upper | alpha-lower`\n- Required fields: none\n- Purpose: A list number style: 1, 2, 3; I, II, III; i, ii, iii; A, B, C; a, b, c. Alphabetic numbering past 26 repeats the letter as PowerPoint does (aa, bb, cc). Roman numerals stop at 3999; larger values are drawn in arabic with a numbering-adapted diagnostic.\n\n_No named properties._\n\n\n### Numbering\n\n- Type: `object`\n- Required fields: none\n- Purpose: Numbering of one list level.\n\n| Field | Required | Type | Notes |\n| --- | --- | --- | --- |\n| `style` | no | `ref:NumberingStyle` | Number style. Default arabic. |\n| `start` | no | `integer` | First number counted at this level. Default 1. Native PowerPoint accepts 1 to 32767. |\n| `suffix` | no | `enum:period \\| paren \\| paren-both` | Text after the number: period (1.), paren (1)) or paren-both ((1)). Default period. |\n\n\n### NumberingSpec\n\n- Type: `oneOf:ref:NumberingStyle / ref:Numbering / array<oneOf:ref:NumberingStyle / ref:Numbering>`\n- Required fields: none\n- Purpose: The value of a numbering field: a style name or Numbering object for every level, or an array with one entry per level (at most 9, the native depth).\n\n_No named properties._\n\n\n### TextRun\n\n- Type: `oneOf:string / object`\n- Required fields: none\n- Purpose: A contiguous run of text. Strings cover unformatted spans; object form adds character formatting.\n\n_No named properties._\n\n\n### Caption\n\n- Type: `oneOf:string / array<ref:TextRun> / object`\n- Required fields: none\n- Purpose: A caption for an image, chart, table or video payload. A string or TextRun[] is the caption text placed below the media; object form adds the position and alignment.\n\n_No named properties._\n\n\n### Reference\n\n- Type: `object`\n- Required fields: `id`, `text`\n- Purpose: A source that runs cite with 'cite'. Cited references are listed in the footnote area of the slides that cite them, numbered per deck in order of first use; referencesSlide() builds an ordinary list slide of them.\n\n| Field | Required | Type | Notes |\n| --- | --- | --- | --- |\n| `id` | yes | `string` | Identifier runs cite. Unique within the references list. |\n| `text` | yes | `oneOf:string / array<ref:TextRun>` | The reference as it is listed: a string or TextRun[] for inline rich text. |\n| `url` | no | `string` | Optional link for the reference; a references slide links its entry to it. |\n\n\n### Chart\n\n- Type: `object`\n- Required fields: `type`, `data`\n- Purpose: Chart content. The chart object keeps chart-specific fields together so slides and regions do not expose loose chart fields.\n\n| Field | Required | Type | Notes |\n| --- | --- | --- | --- |\n| `type` | yes | `string` | Chart type id. Resolves to the id of a chartTypes catalog record; renderers map that record through mappings.openxml and any renderer-specific mapping they understand. The bundled catalog covers the chart types Aspose... |\n| `data` | yes | `oneOf:ref:ChartData / ref:DatasetRef` | Chart data. Inline data uses a tabular columns/rows shape; renderers convert rows to chart series internally. A DatasetRef ({ \"dataset\": \"<id>\" }) plots a top-level dataset instead. Data from a spreadsheet or file is... |\n| `mapping` | no | `ref:ChartMapping` | Optional series mapping by column name: which column is the category, which is the X column of a scatter chart, and which columns are plotted, in order. Absent keeps the positional rule (first column the category, the... |\n| `line` | no | `array<string>` | Combo charts only (type 'combo'): the plotted series, by column name, that are drawn as lines with markers; every other plotted series is drawn as clustered columns. Absent: the last plotted series is the line. A comb... |\n| `secondaryAxis` | no | `array<string>` | Combo charts only: line series, by column name, plotted against a secondary value axis at the right of the plot. That axis has its own scale, and its tick labels use the number format of the first secondary series' co... |\n| `axisTitles` | no | `ref:ChartAxisTitles` | Optional axis titles. Absent keeps today's untitled axes. Supported on the chart types that have a category/value (or X/Y) axis pair (column, bar, line, area, scatter, and the histogram, pareto, waterfall and box-and-... |\n| `legend` | no | `enum:none \\| top \\| bottom \\| left \\| right` | Optional legend position. 'none' hides the legend. Absent keeps today's behaviour exactly (a legend at the right of multi-series charts and of pie and doughnut charts, none for single-series charts). A named position... |\n| `dataLabels` | no | `oneOf:boolean / ref:ChartDataLabels` | Optional data labels. true shows value labels at each type's default position, false (or absent) shows none, which is today's behaviour. Use the object form for the label content, position and separator. |\n| `alt` | no | `string` | Text alternative for the chart: what the data shows (the point and the key numbers), not 'a chart' or 'bar chart'. The preview exposes it as the chart's accessible name (role img with aria-label) and the PowerPoint ex... |\n| `highlight` | no | `ref:ChartHighlight` | Optional emphasis: the series and/or categories that carry the message. The engines draw the highlighted marks in the deck's primary (accent) color and every other mark in a muted neutral derived from the theme (a lig... |\n\n\n### ChartAxisTitles\n\n- Type: `object`\n- Required fields: none\n- Purpose: Titles for the axes of a chart. 'category' is the axis that carries the row labels (the horizontal axis of a column or line chart, the vertical axis of a bar chart, the X axis of a scatter chart); 'value' is the other axis; 'secondary' is the secondary value axis of a combo chart.\n\n| Field | Required | Type | Notes |\n| --- | --- | --- | --- |\n| `category` | no | `string` | Title of the category (X) axis. |\n| `value` | no | `string` | Title of the value (Y) axis. On a combo chart, the primary (left) value axis. |\n| `secondary` | no | `string` | Combo charts only: title of the secondary value axis at the right of the plot, drawn rotated like the primary value axis title. Dropped with a 'chart-option-adapted' diagnostic on any other chart type and on a combo c... |\n\n\n### ChartHighlight\n\n- Type: `object`\n- Required fields: none\n- Purpose: Which series and categories a chart emphasizes. Names are matched exactly: series by data column name (after any 'fields' selection; a series is a plotted value column), categories by row label (the first column, or mapping.category), as text. A category label shared by several rows highlights each of them.\n\n| Field | Required | Type | Notes |\n| --- | --- | --- | --- |\n| `series` | no | `array<string>` | Series (value columns) to highlight: all marks of each named series. A name that is not a column is a 'chart-highlight-unknown-name' error; a column that is not plotted as a series (the category or X column, or one ma... |\n| `categories` | no | `array<string>` | Categories (row labels) to highlight: the marks of each named category (a column or bar of every series, a line's points, a pie or doughnut slice). A label no row has is a 'chart-highlight-unknown-name' error. |\n\n\n### ChartDataLabels\n\n- Type: `object`\n- Required fields: none\n- Purpose: Data label settings. A label shows the selected content parts in the fixed order category, value, percent, joined by the separator.\n\n| Field | Required | Type | Notes |\n| --- | --- | --- | --- |\n| `content` | no | `array<enum:value \\| percent \\| category>` | Which parts a label shows. Defaults to ['value']. 'percent' is the share of the total and exists only on pie and doughnut charts; 'category' shows the category name (the X value on a scatter chart). A part a chart typ... |\n| `position` | no | `enum:auto \\| center \\| inside-end \\| inside-base \\| outside-end \\| above \\| below \\| left \\| right` | Where a label sits relative to its mark. 'auto' (the default) is the type's default: outside-end for clustered columns and bars, pie slices and the histogram, pareto and waterfall constructs; center for stacked column... |\n| `separator` | no | `string` | Text between the parts of a label that shows more than one. Defaults to ', '. |\n\n\n### Table\n\n- Type: `object`\n- Required fields: none\n- Purpose: Table content, inline or dataset-backed. An inline table has 'rows' (required) and optional 'columns'. A dataset-backed table has 'dataset' (required) and optional 'fields', and no 'rows' or 'columns': it takes its headers, rows and column formats from the dataset (per-cell styles need an inline table).\n- Conditional requirement: `dataset`, or otherwise `rows`\n\n| Field | Required | Type | Notes |\n| --- | --- | --- | --- |\n| `columns` | no | `array<oneOf:string / array<ref:TextRun> / ref:StyledTableCell / ref:DataColumn / null>` | Optional column labels. Labels may be strings, rich runs, styled cell objects or DataColumn objects ({ \"name\", \"format\" }). A header's 'format' (DataColumn or StyledTableCell) is the column's number format; a body cel... |\n| `rows` | no | `array<array<ref:TableCell>>` | Two-dimensional table row data; each row aligns by index with columns when columns are supplied. |\n| `dataset` | no | `ref:DatasetId` | Id of a top-level dataset that supplies this table's headers, rows and column formats. Excludes 'rows' and 'columns'. An unknown id is a 'dataset-unknown' error. |\n| `fields` | no | `ref:DatasetFields` | Dataset tables only: the dataset columns to show, by name and in order. Absent shows every column. An unknown name is a 'dataset-field-unknown' error. |\n\n\n### ChartData\n\n- Type: `object`\n- Required fields: `columns`, `rows`\n- Purpose: Inline tabular data driving a chart. The first column usually supplies category/x-axis labels; subsequent columns are plotted measures unless 'chart.mapping' or a chart type maps them differently. Value cells are numbers; a string is read only in strict decimal syntax ('12', '-3.5', '1e6'), and anything else ('12%', '$5', '(5)', '1,234') is a gap and a 'chart-value-not-numeric' warning.\n\n| Field | Required | Type | Notes |\n| --- | --- | --- | --- |\n| `columns` | yes | `array<oneOf:string / ref:DataColumn>` | Ordered column labels for the chart data table. A label is a string or a DataColumn ({ \"name\", \"format\" }) whose number format the data labels, value axis and exported workbook use. |\n| `rows` | yes | `array<array<ref:ChartDataCell>>` | Tabular chart rows. Each row aligns by index with columns. |\n| `source` | no | `ref:DataSourceRef` | Optional provenance: where this inline data came from. Engines never read, fetch or refresh it; they keep it through editing, export and re-import. |\n\n\n### DataColumn\n\n- Type: `object`\n- Required fields: `name`\n- Purpose: A named data column with an optional number format. Anywhere a chart or dataset column is a string it may be a DataColumn; the string form is { \"name\": value } with no format. A table column header may also be a DataColumn.\n\n| Field | Required | Type | Notes |\n| --- | --- | --- | --- |\n| `name` | yes | `string` | Column label, used as the series name, the table header and the name 'fields' and 'chart.mapping' address. |\n| `format` | no | `ref:NumberFormat` | Number format of the column's number values (data labels, value axis, table cells, exported workbook). Absent: the General form. |\n\n\n### DataSourceRef\n\n- Type: `object`\n- Required fields: `src`\n- Purpose: Provenance of inline data or a dataset: the file, sheet and range it came from and when. Engines never read, fetch or refresh it; they keep it through editing, export and re-import. Re-import the data to update it.\n\n| Field | Required | Type | Notes |\n| --- | --- | --- | --- |\n| `src` | yes | `string` | Where the data came from: an 'asset:<id>' reference, a URL, or a relative or local path. |\n| `sheet` | no | `string` | Sheet or table name inside a spreadsheet source. |\n| `range` | no | `string` | A1-style range inside a spreadsheet source. |\n| `fields` | no | `array<string>` | The source fields the data was taken from, in order. |\n| `retrieved` | no | `anyOf:schema / schema` | When the data was taken from the source: an ISO 8601 date or date-time. |\n| `description` | no | `string` | Free-form note about the source. |\n\n\n### DatasetId\n\n- Type: `string`\n- Required fields: none\n- Purpose: Id of an entry in the top-level datasets map (the assets id pattern).\n\n_No named properties._\n\n\n### DatasetFields\n\n- Type: `array<string>`\n- Required fields: none\n- Purpose: Dataset column names to use, in order, each at most once. Selects and orders the dataset's columns; an unknown name is a 'dataset-field-unknown' error.\n\n_No named properties._\n\n\n### Datasets\n\n- Type: `object`\n- Required fields: none\n- Purpose: Shared data tables keyed by id (the assets id pattern). Charts reference one with 'chart.data': { \"dataset\": \"<id>\" } and tables with 'table': { \"dataset\": \"<id>\" }.\n\n_No named properties._\n\n\n### Dataset\n\n- Type: `object`\n- Required fields: `columns`, `rows`\n- Purpose: One shared data table: named columns (strings or DataColumn objects with a number format) and rows of scalar cells. Column names are unique ('data-column-duplicate' error). Rich text and styling belong in an inline table.\n\n| Field | Required | Type | Notes |\n| --- | --- | --- | --- |\n| `title` | no | `string` | Human name of the dataset, shown by editors. |\n| `description` | no | `string` | What the dataset holds. |\n| `columns` | yes | `array<oneOf:string / ref:DataColumn>` | Ordered, uniquely named columns. |\n| `rows` | yes | `array<array<ref:ChartDataCell>>` | Rows of scalar cells (string, number, boolean, null); each row aligns by index with columns. |\n| `source` | no | `ref:DataSourceRef` | Optional provenance. Engines never read, fetch or refresh it. |\n\n\n### DatasetRef\n\n- Type: `object`\n- Required fields: `dataset`\n- Purpose: Chart data taken from a top-level dataset. Engines inline it before plotting; 'chart.mapping' names columns after the 'fields' selection.\n\n| Field | Required | Type | Notes |\n| --- | --- | --- | --- |\n| `dataset` | yes | `ref:DatasetId` | Id of the dataset. An unknown id is a 'dataset-unknown' error. |\n| `fields` | no | `ref:DatasetFields` | The dataset columns to use, by name and in order. Absent uses every column. |\n\n\n### ChartMapping\n\n- Type: `object`\n- Required fields: none\n- Purpose: Series mapping by column name (after any 'fields' selection). Mapping only selects and orders columns; pie, doughnut and other single-series constructs still plot one series. An unknown name is a 'chart-mapping-unknown-column' error; a series that repeats the category or X column, and an X column on a chart type without an X axis, are dropped with a 'chart-mapping-adapted' warning.\n\n| Field | Required | Type | Notes |\n| --- | --- | --- | --- |\n| `category` | no | `string` | The label (category) column. Default: the first column. |\n| `x` | no | `string` | The X column of an XY (scatter) chart. Default: the second column (the first, when the category is the second column), and only when there are three or more columns: with two, the second column is the one series, plot... |\n| `series` | no | `array<string>` | The plotted columns, in order. Default: every column that is not the category or the X column. |\n\n\n### ChartDataCell\n\n- Type: `oneOf:string / number / boolean / null`\n- Required fields: none\n- Purpose: A cell in inline chart data.\n\n_No named properties._\n\n\n### TableCell\n\n- Type: `oneOf:ref:TableCellValue / ref:StyledTableCell`\n- Required fields: none\n- Purpose: A scalar, rich-run array, or styled/spanning cell object. Existing scalar and rich forms remain valid.\n\n_No named properties._\n\n\n### TableCellValue\n\n- Type: `oneOf:string / number / boolean / null / array<ref:TextRun>`\n- Required fields: none\n- Purpose: A scalar table value or canonical rich text runs, without cell decoration or geometry.\n\n_No named properties._\n\n\n### StyledTableCell\n\n- Type: `object`\n- Required fields: `value`\n- Purpose: A cell with explicit visual style or merged geometry. Its position remains its array column index; use null placeholders for every covered grid position.\n\n| Field | Required | Type | Notes |\n| --- | --- | --- | --- |\n| `value` | yes | `ref:TableCellValue` | Editable cell content; styling and spans do not change its scalar type or rich runs. |\n| `style` | no | `ref:TableCellStyle` | |\n| `colSpan` | no | `integer` | Number of grid columns covered, starting at this cell. Covered positions must contain null. Default 1. |\n| `rowSpan` | no | `integer` | Number of grid rows covered, starting at this cell. Covered positions must contain null. Header cells cannot span into body rows. Default 1. |\n| `format` | no | `ref:NumberFormat` | Number format. On a body cell it formats that cell's number value (and wins over the column's format); on a header cell (in 'columns') it is the column's format, the same as a DataColumn header. Strings, booleans, nul... |\n\n\n### TableCellStyle\n\n- Type: `object`\n- Required fields: none\n- Purpose: Cell appearance. Sizes use reference pixels at a 720-pixel canvas short edge and scale with the slide.\n\n| Field | Required | Type | Notes |\n| --- | --- | --- | --- |\n| `fill` | no | `ref:ColorRef` | Cell background: a hex color, a color-scheme slot or role name, or a 'var:<id>' variable reference. Eight-digit hex colors include alpha; #00000000 is transparent. |\n| `color` | no | `ref:ColorRef` | Default text color, overridden by individual rich run colors. Accepts a hex color, a color-scheme slot or role name, or a 'var:<id>' variable reference. |\n| `align` | no | `enum:left \\| center \\| right` | Horizontal text alignment inside the cell. |\n| `verticalAlign` | no | `enum:top \\| middle \\| bottom` | Vertical alignment inside the padded cell box. |\n| `padding` | no | `ref:TableCellPadding` | |\n| `borders` | no | `object` | Independent cell edges. Omitted edges retain the table theme border; width 0 removes an edge. |\n\n\n### TableCellPadding\n\n- Type: `object`\n- Required fields: none\n- Purpose: Text insets in reference pixels. Defaults: top 8, right 10, bottom 4, left 10.\n\n| Field | Required | Type | Notes |\n| --- | --- | --- | --- |\n| `top` | no | `number` | |\n| `right` | no | `number` | |\n| `bottom` | no | `number` | |\n| `left` | no | `number` | |\n\n\n### TableCellBorder\n\n- Type: `object`\n- Required fields: `color`, `width`\n- Purpose: One explicit cell border.\n\n| Field | Required | Type | Notes |\n| --- | --- | --- | --- |\n| `color` | yes | `ref:ColorRef` | Border color: a hex color, a color-scheme slot or role name, or a 'var:<id>' variable reference. Eight-digit hex colors include alpha; #00000000 is transparent. |\n| `width` | yes | `number` | Border width in reference pixels; 0 removes this edge. |\n| `dash` | no | `enum:solid \\| dash \\| dot` | Default solid. |\n\n\n### Catalogs\n\n- Type: `object`\n- Required fields: none\n- Purpose: Catalog overrides for the in-document references. Every property is optional. The default catalog for a kind lives at https://www.pptx.gallery/<kind> (e.g. https://www.pptx.gallery/narratives, https://www.pptx.gallery/themes). pptx.gallery is its canonical publisher: GET https://www.pptx.gallery/<kind>/index.json (or https://www.pptx.gallery/<kind> with Accept: application/json) returns a catalog index (https://openpresentation.org/schema/opf-catalog-index/v1) and https://www.pptx.gallery/<ki...\n\n| Field | Required | Type | Notes |\n| --- | --- | --- | --- |\n| `narratives` | no | `ref:CatalogEntry` | Catalog of narrative templates. Records validate against https://openpresentation.org/schema/opf-narrative/v1. A custom narrative goes in 'catalogs.narratives.records' and is referenced by its id from the root 'narrat... |\n| `themes` | no | `ref:CatalogEntry` | Catalog of themes. Records validate against https://openpresentation.org/schema/opf-theme/v1. Default source: https://www.pptx.gallery/themes. |\n| `colorSchemes` | no | `ref:CatalogEntry` | Catalog of color schemes. Records validate against https://openpresentation.org/schema/opf-color-scheme/v1. Default source: https://www.pptx.gallery/color-schemes. |\n| `fontSchemes` | no | `ref:CatalogEntry` | Catalog of font schemes. Records validate against https://openpresentation.org/schema/opf-font-scheme/v1. Default source: https://www.pptx.gallery/font-schemes. |\n| `languages` | no | `ref:CatalogEntry` | Catalog of languages. Records validate against https://openpresentation.org/schema/opf-language/v1. Default source: https://www.pptx.gallery/languages. |\n| `layouts` | no | `ref:CatalogEntry` | Catalog of slide layouts. Records validate against https://openpresentation.org/schema/opf-layout/v1. Default source: https://www.pptx.gallery/layouts. |\n| `chartTypes` | no | `ref:CatalogEntry` | Catalog of chart types. Records validate against https://openpresentation.org/schema/opf-chart-type/v1. Default source: https://www.pptx.gallery/chart-types. |\n| `tones` | no | `ref:CatalogEntry` | Catalog of presentation tones. Records validate against https://openpresentation.org/schema/opf-tone/v1. Default source: https://www.pptx.gallery/tones. Referenced from tone. |\n| `purposes` | no | `ref:CatalogEntry` | Catalog of presentation purposes. Records validate against https://openpresentation.org/schema/opf-purpose/v1. Default source: https://www.pptx.gallery/purposes. Referenced from purpose. |\n| `audiences` | no | `ref:CatalogEntry` | Catalog of presentation audiences. Records validate against https://openpresentation.org/schema/opf-audience/v1. Default source: https://www.pptx.gallery/audiences. Referenced from audience. |\n| `socialPlatforms` | no | `ref:CatalogEntry` | Catalog of social-media platforms. Records validate against https://openpresentation.org/schema/opf-social-platform/v1. Default source: https://www.pptx.gallery/social-platforms. Referenced via the property keys of an... |\n\n\n### CatalogEntry\n\n- Type: `object`\n- Required fields: none\n- Purpose: A catalog override for one record kind. 'source' replaces the default registry; 'records' adds inline records that take precedence over anything fetched from a source. Either or both may be provided; both omitted means the kind uses its default catalog.\n\n| Field | Required | Type | Notes |\n| --- | --- | --- | --- |\n| `source` | no | `oneOf:ref:CatalogSource / array<ref:CatalogSource>` | Single source or an ordered search path of sources. When omitted, the engine falls back to the default catalog at https://www.pptx.gallery/<kind>, resolved from its bundled snapshot. Engines never fetch a source. The... |\n| `records` | no | `array<object>` | Inline catalog records embedded in this OPF document. Each record validates against the kind's companion schema (e.g. https://openpresentation.org/schema/opf-narrative/v1 for narratives). Inline records win over anyth... |\n\n\n### CatalogSource\n\n- Type: `string`\n- Required fields: none\n- Purpose: Catalog source location. Accepts: - A bare URL pointing at a catalog directory (e.g. 'https://acme.com/decks/narratives'); record ids resolve to '<base>/<id>.json'. - A URL pointing at an index file (e.g. 'https://acme.com/decks/narratives/index.json'); records are resolved relative to the index file's directory and the index entries describe what's available. Index files follow https://openpresentation.org/schema/opf-catalog-index/v1; the default catalog's index is https://www.pptx.gallery/<...\n\n_No named properties._\n\n\n### Composition\n\n- Type: `object`\n- Required fields: none\n- Purpose: Portable dynamic composition. Slide fields override the resolved layout. Nested groups arrange their children independently, inheriting only minFontSize and overflow. Explicit promoted regions retain their positions.\n\n| Field | Required | Type | Notes |\n| --- | --- | --- | --- |\n| `mode` | no | `enum:auto \\| grid \\| row \\| column` | auto chooses a grid from available space and content; grid uses columns; row and column use one horizontal or vertical track. |\n| `columns` | no | `integer` | Column count for grid. In auto mode this caps the number of columns. |\n| `gap` | no | `number` | Space between cells as a fraction of the container short edge (canvas at slide root). Default 0.03333333333333333. |\n| `padding` | no | `number` | Inset as a fraction of the container short edge. Default 0.08 on a slide, 0 inside a group. |\n| `weights` | no | `array<number>` | Relative track sizes: columns for row/grid/auto, rows for column. Omitted tracks have weight 1; extra weights are ignored. |\n| `minFontSize` | no | `number` | Minimum readable text size in reference pixels at a 720-pixel canvas short edge. Default 16. Overflow is diagnosed when text cannot fit at this size. |\n| `overflow` | no | `enum:warn \\| error` | warn returns diagnostics for content that does not fit; error rejects layout. Content is never silently removed. Default warn. |\n"
283
+ "markdown": "# OPF Presentation Schema Reference\n\nThis reference documents the author-facing shape of a complete `*.opf.json` presentation document. It summarizes the canonical schema in `spec/schemas/opf.schema.json`; the schema remains the source of truth for validators.\n\n## Document Contract\n\n- Schema id: `https://openpresentation.org/schema/opf/v1`\n- Required top-level fields: `slides`\n- Additional top-level fields: not allowed\n\n## Top-Level Fields\n\n| Field | Required | Type | Notes |\n| --- | --- | --- | --- |\n| `$schema` | no | `const:\"https://openpresentation.org/schema/opf/v1\"` | Optional OPF schema version. When omitted, validators and engines should assume the latest supported OPF schema. |\n| `name` | no | `string` | Display name of the presentation for GUI/TUI lists, library/search indexing, OS-level metadata, and default export filenames. This is deck identity, not slide content. Use slides[].title and slides[].subtitle for text... |\n| `description` | no | `string` | Free-form prose describing what this presentation is about. Used by agents and humans as a deck-level summary; complements purpose (the goal) and narrative (the structured storyline). Round-trips to OOXML 'docProps/co... |\n| `filename` | no | `string` | Optional base filename for exports (without extension). The opf CLI (render, export) and the editor strip a trailing .pptx, .pdf, .png, or .svg (case-insensitive) and append the target format's extension. When omitted... |\n| `organization` | no | `oneOf:ref:Organization / array<ref:Organization>` | Organization associated with the presentation, usually the presenting company. Array form supports hosts, partners, clients, and sponsors. The primary organization (Organization.role 'primary', else the first item) su... |\n| `speaker` | no | `oneOf:ref:Speaker / array<ref:Speaker>` | Person presenting the deck. Array form supports panels and multi-speaker decks. The first speaker is the primary speaker: the built-in variables 'speaker.<field>' and the 'speaker' header/footer field read it, 'speake... |\n| `author` | no | `oneOf:string / array<string>` | Optional credit for the person who authored or contributed to the deck, distinct from speaker. Array form supports multiple contributors. Round-trips to OOXML 'docProps/core.xml' as '<dc:creator>' (semicolon-joined wh... |\n| `audience` | no | `oneOf:string / ref:Audience / array<oneOf:string / ref:Audience>` | Intended audiences for the presentation. Accepts any of: - A single string: an audiences reference ('executive', or 'acme:board' for a record of the catalogs.acme group), or free-form text ('Series B investors'). A st... |\n| `purpose` | no | `oneOf:string / ref:Purpose` | Primary goal of the presentation. Accepts either: - A string: a purposes reference ('decide', or 'acme:fundraise' for a record of the catalogs.acme group), or a free-form goal ('Raise a Series B round of $30M'). A str... |\n| `language` | no | `oneOf:ref:LanguageTag / ref:Language` | Language of the presentation content, an engine vocabulary: a BCP-47 language tag, validated directly with no catalog lookup. Accepts either: - A BCP-47 tag ('en-US', 'en-GB', 'ja', 'zh-Hans', 'ar-SA'). - An inline La... |\n| `tone` | no | `oneOf:ref:CatalogReference / ref:Tone` | Desired tone for the presentation. Accepts either: - A tones reference: a bare id ('formal') or 'name:id' for a record of a named catalogs group. - An inline Tone object for custom tone metadata or overrides on a refe... |\n| `takeaway` | no | `oneOf:string / array<string>` | Audience-facing takeaway the presentation should leave behind. Array form supports multiple takeaways. Deck-level intent used by AI to seed and pressure-test slide content. |\n| `duration` | no | `integer` | Target presentation duration, as an integer number of minutes. The opf-render presenter view counts the elapsed time against it, and core validate warns when it lies outside the resolved narrative's 'duration' range.... |\n| `tags` | no | `array<string>` | Free-form labels used for categorization, search, and filtering. Lowercase kebab-case is recommended for consistency across a deck library. |\n| `design` | no | `ref:Design` | Optional design system covering theme, color scheme, font scheme, dimensions, background, logo, watermark, header, and footer applied to the deck. When omitted, engines use their default design configuration. |\n| `variables` | no | `ref:Variables` | Optional named variables: deck colors referenced as 'var:<id>' (the original use), and typed content variables (text, number, date, image, url, list) referenced inline as '{{<id>}}' or whole as 'var:<id>'. Variables a... |\n| `template` | no | `boolean` | Marks this document as a template: an incomplete OPF file. A template declares variables (top-level 'variables') and references them from content, and may leave required variables unfilled; validation then reports the... |\n| `narrative` | no | `ref:CatalogReference` | The deck's narrative plan, by reference: a bare narratives id ('classic-story') or 'name:id' for a record of a named catalogs group ('acme:founder-pitch'). The deck holds only this pointer; the plan (the arc, its beat... |\n| `slides` | yes | `array<ref:Slide>` | Ordered array of slides that make up the presentation. |\n| `references` | no | `array<ref:Reference>` | Sources that text runs cite with 'cite'. Ids are unique. A cited reference is listed in the footnote area of every slide that cites it, with a marker number assigned per deck in order of first use; a reference no run... |\n| `datasets` | no | `ref:Datasets` | Optional shared data tables, keyed by id. A chart ('chart.data': { \"dataset\": \"<id>\" }) or a table ('table': { \"dataset\": \"<id>\" }) references one instead of holding its own copy; engines inline the reference before c... |\n| `assets` | no | `ref:Assets` | Optional reusable asset registry for images, data files, videos, documents, fonts, and other resources referenced elsewhere in the deck via 'asset:<id>' strings. |\n| `catalogs` | no | `ref:Catalogs` | The catalog records this document embeds, grouped by the catalog they came from: 'default' (the catalog bare ids come from), 'custom' (records the document defines itself) and any number of named catalogs ('acme', ref... |\n| `extensions` | no | `object` | Custom data passthrough for agent workflows; ignored by the engine but preserved across read/write round-trips. |\n\n## Object And Type Reference\n\n### Assets\n\n- Type: `object`\n- Required fields: none\n- Purpose: Reusable asset registry for resources used by slides, charts, metadata, and design. Keys are stable asset ids referenced elsewhere as 'asset:<id>'. Each asset can be a source string or an object with src plus optional metadata.\n\n_No named properties._\n\n\n### Asset\n\n- Type: `oneOf:string / object`\n- Required fields: none\n- Purpose: Reusable or inline resource. A string is shorthand for { \"src\": value }. Source strings accept 'asset:<id>' references, HTTPS URLs, data URIs, relative paths resolved against the OPF file location, or local filesystem paths. Use object form when metadata such as alt text, title or mediaType matters.\n\n_No named properties._\n\n\n### Audience\n\n- Type: `anyOf:schema / schema`\n- Required fields: none\n- Purpose: Inline audience metadata for the presentation. Use 'id' to reference an audiences record (a bare id or 'name:id') and override selected fields, or use 'name' for a custom inline audience.\n- Conditional requirement: `id` or `name`\n\n| Field | Required | Type | Notes |\n| --- | --- | --- | --- |\n| `id` | no | `ref:CatalogReference` | Optional audiences reference (a bare id or 'name:id') resolved before applying the inline overrides. |\n| `name` | no | `string` | Human-readable audience name shown in pickers. |\n| `summary` | no | `string` | One-sentence positioning of the audience. |\n| `description` | no | `string` | Longer prose describing the audience and how to address them. |\n| `seniority` | no | `enum:ic \\| manager \\| director \\| vp \\| c-suite \\| mixed` | Typical seniority level of the audience. |\n| `technicalFluency` | no | `enum:low \\| medium \\| high \\| mixed` | Typical technical fluency of the audience. |\n| `decisionPower` | no | `enum:informational \\| advisory \\| decision-maker` | Whether the audience is expected to be informed, advise, or decide. |\n| `attentionBudgetMinutes` | no | `number` | Realistic upper bound on focused attention for a single presentation, in minutes. |\n| `recommendedNarratives` | no | `array<string>` | Soft cross-link: narrative ids that work well for this audience, for picker UIs. Never resolved, embedded or validated as references. |\n| `recommendedTones` | no | `array<string>` | Soft cross-link: tone ids that work well for this audience, for picker UIs. Never resolved, embedded or validated as references. |\n| `tags` | no | `array<string>` | Free-form labels for filtering and search. |\n\n\n### Purpose\n\n- Type: `anyOf:schema / schema`\n- Required fields: none\n- Purpose: Inline purpose metadata for the presentation. Use 'id' to reference a purposes record (a bare id or 'name:id') and override selected fields, or use 'name' for a custom inline purpose.\n- Conditional requirement: `id` or `name`\n\n| Field | Required | Type | Notes |\n| --- | --- | --- | --- |\n| `id` | no | `ref:CatalogReference` | Optional purposes reference (a bare id or 'name:id') resolved before applying the inline overrides. |\n| `name` | no | `string` | Human-readable purpose name shown in pickers. |\n| `summary` | no | `string` | One-sentence positioning of the purpose. |\n| `description` | no | `string` | Longer prose describing when to use this purpose and how it should shape a deck. |\n| `outcome` | no | `string` | Desired audience outcome after the presentation. |\n| `successCriteria` | no | `array<string>` | Observable signals that the deck accomplished this purpose. |\n| `recommendedNarratives` | no | `array<string>` | Soft cross-link: narrative ids that work well for this purpose, for picker UIs. Never resolved, embedded or validated as references. |\n| `recommendedTones` | no | `array<string>` | Soft cross-link: tone ids that work well for this purpose, for picker UIs. Never resolved, embedded or validated as references. |\n| `tags` | no | `array<string>` | Free-form labels for filtering and search. |\n\n\n### Language\n\n- Type: `object`\n- Required fields: `bcp47`\n- Purpose: Inline language metadata for the presentation: a BCP-47 'bcp47' tag plus overrides of what engines know about it. Languages are an engine vocabulary, not catalog records, so there is no 'id'.\n\n| Field | Required | Type | Notes |\n| --- | --- | --- | --- |\n| `name` | no | `string` | Human-readable language name. |\n| `bcp47` | yes | `ref:LanguageTag` | BCP-47 language tag used for locale-aware rendering, proofing, and accessibility metadata. Use 'en-GB' for UK English; 'en-UK' is not a valid BCP-47 region form. |\n| `ooxmlLang` | no | `string` | Culture tag for OOXML text-run language attributes (a:rPr/@lang, a:endParaRPr/@lang), in the language-[Script-]REGION form Office recognizes (e.g. 'ja-JP', 'ar-SA', 'ms-MY', 'nb-NO', 'fil-PH', 'zh-CN'). Without it, en... |\n| `code` | no | `string` | ISO 639-3 or 639-2 language code carried for engines that prefer ISO codes. |\n| `direction` | no | `enum:ltr \\| rtl` | Base text direction for the language. When omitted, engines derive it from the script: Arabic (Arab), Hebrew (Hebr), Syriac (Syrc), Thaana (Thaa), N'Ko (Nkoo), Adlam (Adlm), Samaritan (Samr), Mandaic (Mand) and Hanifi... |\n| `script` | no | `string` | ISO 15924 script code of the language's writing system. The script selects the OOXML font slot the language's text uses: East Asian scripts (Hans, Hant, Hani, Jpan, Kore, Hang, Hira, Kana, Bopo, Yiii) use the eastAsia... |\n| `fontScheme` | no | `ref:CatalogReference` | Font-scheme reference (a bare id or 'name:id') for this language's script slot when targeting PowerPoint output. Its major/minor families fill the language's script slot (eastAsian or complexScript, chosen by 'script'... |\n| `googleFontScheme` | no | `ref:CatalogReference` | Font-scheme reference (a bare id or 'name:id') used in place of 'fontScheme' when resolving script fonts for Google Slides output. |\n| `summary` | no | `string` | One-sentence note about coverage or font defaults. |\n| `description` | no | `string` | Longer prose describing the language record and any font-pairing rationale. |\n| `tags` | no | `array<string>` | Free-form labels for filtering and search. |\n\n\n### Tone\n\n- Type: `anyOf:schema / schema`\n- Required fields: none\n- Purpose: Inline tone metadata for the presentation. Use 'id' to reference a tones record (a bare id or 'name:id') and override selected fields, or use 'name' for a custom inline tone.\n- Conditional requirement: `id` or `name`\n\n| Field | Required | Type | Notes |\n| --- | --- | --- | --- |\n| `id` | no | `ref:CatalogReference` | Optional tones reference (a bare id or 'name:id') resolved before applying the inline overrides. |\n| `name` | no | `string` | Human-readable tone name shown in pickers. |\n| `summary` | no | `string` | One-sentence positioning of the tone. |\n| `description` | no | `string` | Longer prose describing the tone and the kinds of decks it suits. |\n| `voiceCues` | no | `array<string>` | Short directives that shape AI generation toward this tone. |\n| `avoid` | no | `array<string>` | Anti-patterns that AI generation should not produce when this tone is active. |\n| `samplePhrases` | no | `array<string>` | Short example phrases that exemplify this tone. |\n| `recommendedNarratives` | no | `array<string>` | Soft cross-link: narrative ids this tone pairs well with, for picker UIs. Never resolved, embedded or validated as references. |\n| `tags` | no | `array<string>` | Free-form labels for filtering and search. |\n\n\n### Organization\n\n- Type: `object`\n- Required fields: `id`, `name`\n- Purpose: An organization associated with the presentation, typically the presenting company, but also hosts, partners, clients, or sponsors. The primary organization (role 'primary', else the first one) supplies the default deck logo (unless design.logo overrides it), the 'organization' header/footer field (its name) and the 'socials' field (its socials). Every field is also a built-in variable ('{{organization.name}}', 'var:organization.logo', 'organization.<id>.<field>'). Nothing else about an organ...\n\n| Field | Required | Type | Notes |\n| --- | --- | --- | --- |\n| `id` | yes | `string` | Stable identifier for the organization, used to reference it from Speaker.organizationId and to address it in built-in variables as 'organization.<id>.<field>'. Must be unique among organizations (a duplicate is a val... |\n| `name` | yes | `string` | Display name. Drawn by the 'organization' header/footer field for the primary organization; the built-in variable 'organization.name' (or 'organization.<id>.name') carries it into any string. |\n| `legalName` | no | `string` | Optional legal entity name when it differs from the display name. Never drawn automatically; available as the built-in variable 'organization.legalName'. |\n| `logo` | no | `ref:Asset` | Source for the organization's logo image. Accepts an HTTPS URL, data URI, relative path (resolved against the OPF file location), local path, or 'asset:<id>' reference. Common formats are SVG (preferred for vector log... |\n| `domain` | no | `string` | Bare internet domain for the organization. Never drawn automatically and not used to look up assets; available as the built-in variable 'organization.domain'. |\n| `email` | no | `string` | General contact email for the organization. Never drawn automatically; available as the built-in variable 'organization.email'. |\n| `phone` | no | `string` | Main contact phone number for the organization. E.164 format is recommended. Never drawn automatically; available as the built-in variable 'organization.phone'. |\n| `tagline` | no | `string` | Short tagline. Never drawn automatically (cover slides do not show it); available as the built-in variable 'organization.tagline', for example in a footer 'text' of '{{organization.tagline}}'. |\n| `role` | no | `enum:primary \\| partner \\| client \\| sponsor \\| host` | Role of the organization relative to the presentation. Only 'primary' has behavior: it selects the primary organization (deck logo, 'organization' and 'socials' header/footer fields, and the 'organization.<field>' bui... |\n| `socials` | no | `ref:Socials` | Optional social media handles or URLs for the organization. The primary organization's socials render in header/footer zones that set socials: true; otherwise they are authoring metadata. |\n\n\n### Speaker\n\n- Type: `object`\n- Required fields: `id`, `name`\n- Purpose: A person presenting the deck. A speaker is drawn only through built-in variables ('{{speaker.name}}' inside any string, 'var:speaker.photo' as a whole image field) and the 'speaker' header/footer field (the first speaker's name and title). No layout, cover or bio slide places a speaker on its own. See docs/templates-and-variables.md.\n\n| Field | Required | Type | Notes |\n| --- | --- | --- | --- |\n| `id` | yes | `string` | Stable identifier for the speaker. Must be unique among speakers (a duplicate is a validation error). Addresses this speaker in built-in variables as 'speaker.<id>.<field>', such as '{{speaker.alice.title}}'. |\n| `name` | yes | `string` | Display name. Built-in variable 'speaker.name' (first speaker) or 'speaker.<id>.name'; the first speaker's name is also drawn by the 'speaker' header/footer field and every name is listed by 'speakers'. |\n| `title` | no | `string` | Role or title. Built-in variable 'speaker.title'; the 'speaker' header/footer field draws it after the name ('Alice Chen, VP of Engineering'). |\n| `photo` | no | `ref:Asset` | Source for the speaker's headshot image. Accepts an HTTPS URL, data URI, relative path (resolved against the OPF file location), local path, or 'asset:<id>' reference. Common formats are JPG or PNG; SVG is not appropr... |\n| `email` | no | `string` | Contact email. Never drawn automatically; available as the built-in variable 'speaker.email'. |\n| `phone` | no | `string` | Contact phone number for the speaker. E.164 format is recommended. Never drawn automatically; available as the built-in variable 'speaker.phone'. |\n| `bio` | no | `string` | Short biographical paragraph. Never drawn automatically; available as the built-in variable 'speaker.bio', for a bio or 'about the speaker' slide you write. |\n| `organizationId` | no | `string` | Reference to an Organization.id in organization; it must name an existing organization (a validation error otherwise). Attribution metadata for hosts: no engine draws it or changes which organization is primary becaus... |\n| `socials` | no | `ref:Socials` | Optional social media handles or URLs for the speaker. Authoring metadata only: nothing draws speaker socials and there is no built-in variable for them (the 'socials' header/footer field draws the primary organizatio... |\n\n\n### Socials\n\n- Type: `object`\n- Required fields: none\n- Purpose: Social media handles or URLs, keyed by platform. The keys are an engine vocabulary validated directly (no catalog lookup): engines link a handle with the platform's URL pattern and handle prefix (spec/reference/engine-vocabularies.json). Each value is a string, either a full URL or a platform handle (e.g., '@acme'). Brand colours and icons are catalog display metadata for authoring UIs; engines render the profile URL, not icons or brand colours. Used both for organization and speaker records.\n\n_No named properties._\n\n\n### Design\n\n- Type: `object`\n- Required fields: none\n- Purpose: Visual design system applied to the presentation; individual slides may override fields via Slide.design.\n\n| Field | Required | Type | Notes |\n| --- | --- | --- | --- |\n| `theme` | no | `ref:CatalogReference` | Theme for the deck: a themes reference, a bare id ('minimal') or 'name:id' ('acme:brand'). The theme supplies the default colour scheme, font scheme, background and dimensions; design.colorScheme, design.fontScheme, d... |\n| `colorScheme` | no | `oneOf:string / ref:ColorScheme` | Color scheme for the presentation. Accepts two forms: - A colorSchemes reference: a bare id ('cool-horizon') or 'name:id' ('acme:ocean'). - Object form: a ColorScheme with an optional 'id' reference as the base plus s... |\n| `fontScheme` | no | `oneOf:string / ref:FontScheme` | Font scheme for heading, body, accent, and code text. Accepts two forms: - A fontSchemes reference: a bare id ('aptos') or 'name:id' ('acme:inter'). - Object form: a FontScheme with an optional 'id' reference as the b... |\n| `dimensions` | no | `oneOf:ref:DimensionPreset / ref:Dimensions` | Slide dimensions and aspect ratio. String shorthand such as 'widescreen' is equivalent to { preset: 'widescreen' }. |\n| `background` | no | `oneOf:ref:BackgroundShortcut / ref:Background` | Default slide background applied across the deck unless overridden on a slide (slides.N.design.background); the resolved theme's background is the fallback. A background is the only canvas fill and never moves content... |\n| `logo` | no | `oneOf:ref:Asset / ref:LogoSet` | Deck logo assets used by covers, section dividers, headers, footers and picture bullets. A string or Asset object is the default logo source; the LogoSet object form provides light/dark, stacked, icon, and wordmark va... |\n| `watermark` | no | `oneOf:const:false / ref:Asset / ref:Watermark` | Optional decorative watermark applied across slides: an image or a text stamp, in the fixed frame and at the opacity described on Watermark. Use false to suppress an inherited watermark in slide-level design; a string... |\n| `header` | no | `oneOf:const:false / ref:HeaderFooter` | Repeated header furniture rendered outside the main slide content. Use false to suppress an inherited header. |\n| `footer` | no | `oneOf:const:false / ref:HeaderFooter` | Repeated footer furniture rendered outside the main slide content. Use false to suppress an inherited footer. |\n| `titleAlignment` | no | `enum:left \\| center \\| right` | Default horizontal alignment for title placeholders in resolved layouts. Effective value: the slide's design, then the deck's design, then the layout record's design.titleAlignment, then the engine default. |\n| `contentAlignment` | no | `enum:left \\| center \\| right` | Default horizontal alignment for body/content regions in resolved layouts. Effective value: the slide's design, then the deck's design, then the layout record's design.contentAlignment, then the engine default. A cove... |\n| `contentBox` | no | `boolean` | Whether body/content regions are rendered inside a visible card or surface. Effective value: the slide's design, then the deck's design, then the layout record's design.contentBox, then the engine default. |\n| `contentDirection` | no | `enum:horizontal \\| vertical` | Axis along which parallel body content is arranged. Sets the root arrangement mode of blocks and root payloads when no composition.mode is set on the slide or on its layout record: 'vertical' is column, 'horizontal' i... |\n| `chartPrimary` | no | `enum:none \\| top \\| bottom \\| left \\| right` | Where the primary chart sits relative to supporting content. Effective value: slide design, then deck design, then the layout record's design.chartPrimary. When the slide has no promoted regions and no composition.mod... |\n| `imageFit` | no | `enum:cover \\| contain \\| stretch` | Default fit of image blocks (and Slide.image) that set no fit of their own: 'cover' fills the frame and crops what overflows around the block's focus, 'contain' shows the whole picture centered in the frame, 'stretch'... |\n| `listBullet` | no | `enum:character \\| image` | Marker style for items and bullets lists. Effective value: the slide's design, then the deck's design, then the layout record's design.listBullet, then 'character'. 'character' (the default) draws the glyph marker. 'i... |\n\n\n### ColorScheme\n\n- Type: `object`\n- Required fields: none\n- Purpose: Color palette used by the design system. Every slot and role is a hex color ('#RGB', '#RRGGBB' or '#RRGGBBAA'); a color-scheme name or a 'var:<id>' reference is not valid here, because those resolve through the scheme. The slot fields (accent1-accent6, dark1, dark2, light1, light2, hyperlink, followedHyperlink) mirror color-scheme.schema.json (https://openpresentation.org/schema/opf-color-scheme/v1) so library records and inline OPF overrides are interchangeable on those fields. Two parallel...\n\n| Field | Required | Type | Notes |\n| --- | --- | --- | --- |\n| `id` | no | `ref:CatalogReference` | Colour-scheme reference (a bare id or 'name:id') resolved as the base; slot and role overrides on the surrounding ColorScheme object take precedence over the resolved scheme. |\n| `accent1` | no | `ref:HexColor` | Accent 1 color (hex). Mirrors the OOXML accent1 slot. |\n| `accent2` | no | `ref:HexColor` | Accent 2 color (hex). Mirrors the OOXML accent2 slot. |\n| `accent3` | no | `ref:HexColor` | Accent 3 color (hex). Mirrors the OOXML accent3 slot. |\n| `accent4` | no | `ref:HexColor` | Accent 4 color (hex). Mirrors the OOXML accent4 slot. |\n| `accent5` | no | `ref:HexColor` | Accent 5 color (hex). Mirrors the OOXML accent5 slot. |\n| `accent6` | no | `ref:HexColor` | Accent 6 color (hex). Mirrors the OOXML accent6 slot. |\n| `dark1` | no | `ref:HexColor` | Dark 1 color (hex). Typically the deepest neutral; OOXML dark1. |\n| `dark2` | no | `ref:HexColor` | Dark 2 color (hex). Secondary dark; OOXML dark2. |\n| `light1` | no | `ref:HexColor` | Light 1 color (hex). Typically the slide canvas; OOXML lt1. |\n| `light2` | no | `ref:HexColor` | Light 2 color (hex). Secondary light surface; OOXML lt2. |\n| `hyperlink` | no | `ref:HexColor` | Hyperlink color (hex). OOXML hlink. Link runs with no color of their own are drawn underlined in it, in the preview and in PowerPoint, unless it has under 4.5:1 contrast against the slide background, then in the slide... |\n| `followedHyperlink` | no | `ref:HexColor` | Followed-hyperlink color (hex). OOXML folHlink. |\n| `primary` | no | `ref:HexColor` | Abstract role: primary brand color (hex). The engine maps this onto an OOXML accent slot when serializing. |\n| `secondary` | no | `ref:HexColor` | Abstract role: secondary brand color (hex). |\n| `accent` | no | `ref:HexColor` | Abstract role: accent color used for highlights and emphasis (hex). |\n| `background` | no | `ref:HexColor` | Abstract role: default slide background color (hex), used when the design names no single-color background (and for gradient and picture backgrounds). Overrides light1 as that default. |\n| `surface` | no | `ref:HexColor` | Abstract role: color for elevated surfaces such as cards and panels (hex). Overrides the default of light2 (dark2 on a dark slide). |\n| `text` | no | `ref:HexColor` | Abstract role: primary body text color (hex). Overrides dark1 on a light slide only; a dark slide always uses light1. |\n| `textSecondary` | no | `ref:HexColor` | Abstract role: secondary or muted text color used for captions and supporting copy (hex). Overrides the default of dark2 (light2 on a dark slide). |\n\n\n### FontScheme\n\n- Type: `object`\n- Required fields: none\n- Purpose: Typography selections used by the design system. The pair fields (major, minor) and refinement fields (type, app, languageFamily) mirror font-scheme.schema.json (https://openpresentation.org/schema/opf-font-scheme/v1) so library records and inline OPF overrides are interchangeable on those fields. Two parallel models are supported and may be mixed: - OOXML pairs (major, minor) - heading and body family names that round-trip directly to PowerPoint majorFont/minorFont entries. - Abstract roles...\n\n| Field | Required | Type | Notes |\n| --- | --- | --- | --- |\n| `id` | no | `ref:CatalogReference` | Font-scheme reference (a bare id or 'name:id') resolved as the base; field overrides on the surrounding FontScheme object take precedence over the resolved scheme. |\n| `major` | no | `string` | Heading (major) font family mirrors the OOXML majorFont entry. Pairs with 'minor'. |\n| `minor` | no | `string` | Body (minor) font family mirrors the OOXML minorFont entry. Pairs with 'major'. |\n| `eastAsian` | no | `object` | East Asian script fonts. Maps to the OOXML a:ea element of majorFont (major) and minorFont (minor), and to run-level a:ea. When set, they fill the eastAsian slot for every language; when omitted, the slot comes from t... |\n| `complexScript` | no | `object` | Complex-script fonts (for example Arabic, Hebrew, Indic and Thai). Maps to the OOXML a:cs element of majorFont (major) and minorFont (minor), and to run-level a:cs. When set, they fill the complexScript slot for every... |\n| `type` | no | `enum:sans-serif \\| serif \\| monospace` | High-level typographic class of the scheme. |\n| `app` | no | `enum:powerpoint \\| google-slides` | Target application this font pairing is intended for. Metadata for pickers and catalog filters: no engine changes its output by it. |\n| `languageFamily` | no | `enum:latin \\| ea \\| cs \\| eastAsian \\| complexScript` | Font-language family this scheme is intended for: 'latin' for Latin-script content, 'ea' (or 'eastAsian', the same value) for East Asian scripts, 'cs' (or 'complexScript', the same value) for Complex Scripts. The long... |\n| `heading` | no | `string` | Abstract role: font family name used for slide titles and headings. Maps onto the OOXML major slot when serializing. |\n| `body` | no | `string` | Abstract role: font family name used for body copy. Maps onto the OOXML minor slot when serializing. |\n| `accent` | no | `string` | Abstract role: font family name used for accent text. When set, the slide tag (eyebrow) and the quote body use this family instead of the body and heading families; nothing else changes. resolveFontFamilies() returns... |\n| `code` | no | `string` | Abstract role: monospaced font family name used for code blocks and for inline code runs (TextRun.code). No direct OOXML slot. Resolution: this override, then the resolved font-scheme record's 'code' (for example Cons... |\n\n\n### SlideDesign\n\n- Type: `allOf:ref:Design + schema`\n- Required fields: none\n- Purpose: A slide's design: every Design field except dimensions. A PPTX has one slide size, so the size is set once, on the deck's design.dimensions (or its theme), and a slide's design cannot set it. A slide-level theme whose resolved dimensions differ from the deck's is a validate warning (opf/slide-theme-dimensions); exporting such a deck to PPTX fails with mixed-slide-dimensions.\n\n_No named properties._\n\n\n### DimensionPreset\n\n- Type: `enum:16:9 | 4:3 | 16:10 | 1:1 | 4:5 | 9:16 | letter | a4 | widescreen | standard`\n- Required fields: none\n- Purpose: Named dimension preset; chooses both aspect ratio and physical size. 'widescreen' is an alias for 16:9 in PowerPoint widescreen size; 'standard' is an alias for 4:3 in PowerPoint standard size. The social-feed ratios keep the widescreen short edge of 7.5 in: 1:1 is 7.5 x 7.5 in, 4:5 is 7.5 x 9.375 in and 9:16 is 7.5 x 13.333 in (portrait).\n\n_No named properties._\n\n\n### Dimensions\n\n- Type: `object`\n- Required fields: none\n- Purpose: Slide dimensions; either pick a preset or specify custom inches.\n\n| Field | Required | Type | Notes |\n| --- | --- | --- | --- |\n| `preset` | no | `ref:DimensionPreset` | |\n| `widthInches` | no | `number` | Custom slide width in inches; overrides the preset width when provided. |\n| `heightInches` | no | `number` | Custom slide height in inches; overrides the preset height when provided. |\n\n\n### ThemeBackgroundSlot\n\n- Type: `enum:light1 | light2 | dark1 | dark2`\n- Required fields: none\n- Purpose: PowerPoint theme-controlled slide background slot from the active color scheme. These are slots, not assumptions about actual colors: light1 is usually white and dark1 is usually black by convention, but the color scheme controls the real values.\n\n_No named properties._\n\n\n### HexColor\n\n- Type: `string`\n- Required fields: none\n- Purpose: Hex color shorthand accepted by selected string fields.\n\n_No named properties._\n\n\n### ColorRef\n\n- Type: `anyOf:ref:HexColor / enum:accent1 | accent2 | accent3 | accent4 | accent5 | accent6 | dark1 | dark2 | light1 | light2 | hyperlink | followedHyperlink | primary | secondary | accent | background | surface | surfaceAlt | text | textSecondary / string`\n- Required fields: none\n- Purpose: A color value or reference, enforced on styled table cell fill and text colors, cell border colors, solid background colors, gradient stop colors and pattern foreground and background colors. Three forms: - Literal hex: '#RGB', '#RRGGBB', or '#RRGGBBAA'. - Color-scheme name, resolved through the effective color scheme after design resolution: an OOXML slot ('accent1'-'accent6', 'dark1', 'dark2', 'light1', 'light2', 'hyperlink', 'followedHyperlink') or an abstract role ('primary', 'secondary',...\n\n_No named properties._\n\n\n### Variables\n\n- Type: `object`\n- Required fields: none\n- Purpose: Named variables, keyed by stable kebab-case id. A variable is a typed, named value the deck declares once and uses in many places: a color ('var:<id>' in color fields, the original use), or content that fills a template (text, number, date, image, url, list). Content is referenced inline as '{{<id>}}' inside any string, or whole as 'var:<id>' in a field of the matching type; '\\{{' writes a literal '{{'. A hex string is shorthand for a color variable. A variable with no 'value' is unfilled: ex...\n\n_No named properties._\n\n\n### Variable\n\n- Type: `oneOf:ref:HexColor / ref:ColorVariable / ref:TextVariable / ref:NumberVariable / ref:DateVariable / ref:ImageVariable / ref:UrlVariable / ref:ListVariable`\n- Required fields: none\n- Purpose: A single named variable: a hex string (shorthand for a color variable) or an object whose 'type' is color, text, number, date, image, url or list. 'value' is the current value and is optional: a variable with no value is unfilled, which a template allows and a normal deck does not. 'example' only illustrates the slot (fill forms, template previews) and never reaches output.\n\n_No named properties._\n\n\n### ColorVariable\n\n- Type: `object`\n- Required fields: `type`\n- Purpose: A named color. Content color fields reference it as 'var:<id>'. Colors resolve at render time through the ordinary color-reference path, so a color variable keeps working as before.\n\n| Field | Required | Type | Notes |\n| --- | --- | --- | --- |\n| `type` | yes | `const:\"color\"` | Variable kind. One of color, text, number, date, image, url or list. |\n| `value` | no | `ref:HexColor` | Hex color this variable resolves to. |\n| `required` | no | `boolean` | Whether the variable must be filled. Defaults to true: a variable with no 'value' is unfilled, and an unfilled required variable is an error in a normal deck and expected in a template. Set false for an optional slot:... |\n| `label` | no | `string` | Optional short human label for forms and fill panels. |\n| `description` | no | `string` | Optional prose describing what the variable is for, surfaced by pickers, fill forms and agents. |\n| `example` | no | `ref:HexColor` | Illustrative color shown in fill forms and used when a template is previewed with examples. Never written to output. |\n\n\n### TextVariable\n\n- Type: `object`\n- Required fields: `type`\n- Purpose: Text content. Plain string or rich TextRun[]. Insert it inline as '{{<id>}}' inside any string (rich text is flattened to plain text there), or reference it whole as 'var:<id>' in a field that accepts string or TextRun[] (the rich runs are kept).\n\n| Field | Required | Type | Notes |\n| --- | --- | --- | --- |\n| `type` | yes | `const:\"text\"` | Variable kind. One of color, text, number, date, image, url or list. |\n| `value` | no | `oneOf:string / array<ref:TextRun>` | Text this variable resolves to: a plain string, or TextRun[] for rich text. |\n| `required` | no | `boolean` | Whether the variable must be filled. Defaults to true: a variable with no 'value' is unfilled, and an unfilled required variable is an error in a normal deck and expected in a template. Set false for an optional slot:... |\n| `label` | no | `string` | Optional short human label for forms and fill panels. |\n| `description` | no | `string` | Optional prose describing what the variable is for, surfaced by pickers, fill forms and agents. |\n| `example` | no | `oneOf:string / array<ref:TextRun>` | Illustrative text shown in fill forms and used when a template is previewed with examples. Never written to output. |\n\n\n### NumberVariable\n\n- Type: `object`\n- Required fields: `type`\n- Purpose: A number. '{{<id>}}' inserts it as text using 'format'; 'var:<id>' as a whole field supplies the number itself (chart values, font sizes).\n\n| Field | Required | Type | Notes |\n| --- | --- | --- | --- |\n| `type` | yes | `const:\"number\"` | Variable kind. One of color, text, number, date, image, url or list. |\n| `value` | no | `number` | Number this variable resolves to. |\n| `required` | no | `boolean` | Whether the variable must be filled. Defaults to true: a variable with no 'value' is unfilled, and an unfilled required variable is an error in a normal deck and expected in a template. Set false for an optional slot:... |\n| `label` | no | `string` | Optional short human label for forms and fill panels. |\n| `description` | no | `string` | Optional prose describing what the variable is for, surfaced by pickers, fill forms and agents. |\n| `example` | no | `number` | Illustrative number shown in fill forms and used when a template is previewed with examples. Never written to output. |\n| `format` | no | `ref:NumberFormat` | Display pattern used by '{{<id>}}' (the shared NumberFormat syntax). A literal prefix, a numeric part of '#', '0', ',' and '.', and a literal suffix. '0' pads digits, '#' is optional, ',' groups thousands, digits afte... |\n\n\n### NumberFormat\n\n- Type: `string`\n- Required fields: none\n- Purpose: Number display pattern, shared by NumberVariable.format, data column formats (DataColumn.format) and table cell formats (StyledTableCell.format). An optional literal prefix, a numeric part of '#', '0', ',' and '.', and an optional literal suffix. '0' pads digits, '#' is optional, ',' groups thousands, digits after '.' fix the decimals ('0' required, '#' optional), and a '%' in the prefix or suffix multiplies the value by 100. English separators only. A format applies only to number values; st...\n\n_No named properties._\n\n\n### DateVariable\n\n- Type: `object`\n- Required fields: `type`\n- Purpose: A calendar date as an ISO YYYY-MM-DD string. No time zone and no clock are involved. '{{<id>}}' inserts it formatted with 'format'.\n\n| Field | Required | Type | Notes |\n| --- | --- | --- | --- |\n| `type` | yes | `const:\"date\"` | Variable kind. One of color, text, number, date, image, url or list. |\n| `value` | no | `string` | ISO calendar date (YYYY-MM-DD) this variable resolves to. |\n| `required` | no | `boolean` | Whether the variable must be filled. Defaults to true: a variable with no 'value' is unfilled, and an unfilled required variable is an error in a normal deck and expected in a template. Set false for an optional slot:... |\n| `label` | no | `string` | Optional short human label for forms and fill panels. |\n| `description` | no | `string` | Optional prose describing what the variable is for, surfaced by pickers, fill forms and agents. |\n| `example` | no | `string` | Illustrative ISO date shown in fill forms and used when a template is previewed with examples. Never written to output. |\n| `format` | no | `string` | Date display pattern, the same LDML-style tokens as header/footer dateFormat: yyyy, yy, MMMM, MMM, MM, M, dd, d, EEEE, EEE and quoted literals. English names. Default 'MMMM d, yyyy'. |\n\n\n### ImageVariable\n\n- Type: `object`\n- Required fields: `type`\n- Purpose: An image source: any Asset (an 'asset:<id>' reference, HTTPS URL, data URI, relative or local path, or an object with src, alt and metadata). Reference it whole as 'var:<id>' in an image, asset or src field; '{{<id>}}' inserts the source string.\n\n| Field | Required | Type | Notes |\n| --- | --- | --- | --- |\n| `type` | yes | `const:\"image\"` | Variable kind. One of color, text, number, date, image, url or list. |\n| `value` | no | `ref:Asset` | Image source this variable resolves to. |\n| `required` | no | `boolean` | Whether the variable must be filled. Defaults to true: a variable with no 'value' is unfilled, and an unfilled required variable is an error in a normal deck and expected in a template. Set false for an optional slot:... |\n| `label` | no | `string` | Optional short human label for forms and fill panels. |\n| `description` | no | `string` | Optional prose describing what the variable is for, surfaced by pickers, fill forms and agents. |\n| `example` | no | `ref:Asset` | Illustrative image source shown in fill forms and used when a template is previewed with examples. Never written to output. |\n\n\n### UrlVariable\n\n- Type: `object`\n- Required fields: `type`\n- Purpose: A link target (http, https, mailto or tel). Use it as '{{<id>}}' inside a link string or reference it whole as 'var:<id>' in a link field.\n\n| Field | Required | Type | Notes |\n| --- | --- | --- | --- |\n| `type` | yes | `const:\"url\"` | Variable kind. One of color, text, number, date, image, url or list. |\n| `value` | no | `string` | Link target this variable resolves to. |\n| `required` | no | `boolean` | Whether the variable must be filled. Defaults to true: a variable with no 'value' is unfilled, and an unfilled required variable is an error in a normal deck and expected in a template. Set false for an optional slot:... |\n| `label` | no | `string` | Optional short human label for forms and fill panels. |\n| `description` | no | `string` | Optional prose describing what the variable is for, surfaced by pickers, fill forms and agents. |\n| `example` | no | `string` | Illustrative link shown in fill forms and used when a template is previewed with examples. Never written to output. |\n\n\n### ListVariable\n\n- Type: `object`\n- Required fields: `type`\n- Purpose: A list of strings, for bullets and list items. A whole-string array element 'var:<id>' splices every entry into the array in place; a whole field 'var:<id>' becomes the array; '{{<id>}}' joins the entries with ', ' (or the separator after a pipe: '{{<id>|; }}').\n\n| Field | Required | Type | Notes |\n| --- | --- | --- | --- |\n| `type` | yes | `const:\"list\"` | Variable kind. One of color, text, number, date, image, url or list. |\n| `value` | no | `array<string>` | Entries this variable resolves to. |\n| `required` | no | `boolean` | Whether the variable must be filled. Defaults to true: a variable with no 'value' is unfilled, and an unfilled required variable is an error in a normal deck and expected in a template. Set false for an optional slot:... |\n| `label` | no | `string` | Optional short human label for forms and fill panels. |\n| `description` | no | `string` | Optional prose describing what the variable is for, surfaced by pickers, fill forms and agents. |\n| `example` | no | `array<string>` | Illustrative entries shown in fill forms and used when a template is previewed with examples. Never written to output. |\n\n\n### BackgroundShortcut\n\n- Type: `oneOf:ref:ThemeBackgroundSlot / ref:HexColor / ref:ImageSource`\n- Required fields: none\n- Purpose: String shorthand for a background. Theme slots ('light1', 'light2', 'dark1', 'dark2') are equivalent to { type: 'theme', slot: value }; hex colors are equivalent to { type: 'solid', color: value }; an image source (starting with 'asset:', 'https://', 'data:', './' or '../') is equivalent to { type: 'image', src: value }, a cover image. Any other string is invalid.\n\n_No named properties._\n\n\n### ImageSource\n\n- Type: `string`\n- Required fields: none\n- Purpose: An image source in a string shorthand: an 'asset:<id>' reference, an HTTPS URL, a data URI, or a relative path starting with './' or '../' (resolved against the OPF file location).\n\n_No named properties._\n\n\n### Background\n\n- Type: `oneOf:ref:ThemeBackground / ref:SolidBackground / ref:GradientBackground / ref:ImageBackground / ref:PatternBackground`\n- Required fields: none\n- Purpose: Background fill applied to slides. Theme backgrounds preserve PowerPoint's color-scheme background choice; other variants represent fixed background fills.\n\n_No named properties._\n\n\n### ThemeBackground\n\n- Type: `object`\n- Required fields: `type`, `slot`\n- Purpose: Theme-controlled PowerPoint slide background. The slot is resolved through the active color scheme and remains theme-aware.\n\n| Field | Required | Type | Notes |\n| --- | --- | --- | --- |\n| `type` | yes | `const:\"theme\"` | Theme-controlled background fill. |\n| `slot` | yes | `ref:ThemeBackgroundSlot` | |\n\n\n### SolidBackground\n\n- Type: `object`\n- Required fields: `type`, `color`\n- Purpose: Fixed solid slide background fill.\n\n| Field | Required | Type | Notes |\n| --- | --- | --- | --- |\n| `type` | yes | `const:\"solid\"` | Fixed solid background fill. |\n| `color` | yes | `ref:ColorRef` | Fixed solid fill color: a hex string, a color-scheme slot or role name, or a var:<id> variable reference (a ColorRef, resolved against the effective color scheme and the deck variables). Use { type: 'theme', slot: ...... |\n| `opacity` | no | `number` | Background opacity from 0 (fully transparent) to 1 (fully opaque). |\n\n\n### GradientBackground\n\n- Type: `object`\n- Required fields: `type`, `gradient`\n- Purpose: Fixed gradient slide background fill.\n\n| Field | Required | Type | Notes |\n| --- | --- | --- | --- |\n| `type` | yes | `const:\"gradient\"` | Fixed gradient background fill. |\n| `gradient` | yes | `object` | Gradient fill definition. |\n| `opacity` | no | `number` | Background opacity from 0 (fully transparent) to 1 (fully opaque). |\n\n\n### ImageBackground\n\n- Type: `object`\n- Required fields: `type`, `src`\n- Purpose: Picture background: the image fills the whole canvas behind everything and never moves content (to push content aside, use an image block with placement). The string shorthand 'asset:x' (or any image source) is { type: 'image', src: 'asset:x' }, a cover image. Paint order: the colour scheme's default slide background, the picture, then the overlay, then furniture and content.\n\n| Field | Required | Type | Notes |\n| --- | --- | --- | --- |\n| `type` | yes | `const:\"image\"` | Picture background. |\n| `src` | yes | `string` | Source for the background image: an 'asset:<id>' reference, HTTPS URL, data URI, or a relative path resolved against the OPF file location. |\n| `alt` | no | `string` | Alternative text. A background with alt is meaningful: PowerPoint export draws it as a full-slide picture at the back that carries the text, so screen readers see it. Without alt it is decorative and exports as the na... |\n| `fit` | no | `enum:cover \\| contain \\| stretch \\| tile` | How the picture fills the canvas: 'cover' fills it and crops what overflows around focus; 'contain' shows the whole picture centered over the colour scheme's default background; 'stretch' scales it to the canvas exact... |\n| `focus` | no | `ref:ImageFocus` | Point of the picture to keep in view when 'cover' crops it. Ignored by the other fits. |\n| `opacity` | no | `number` | Picture opacity from 0 (fully transparent) to 1 (fully opaque); the overlay keeps its own opacity. Default 1. |\n| `recolor` | no | `ref:ImageRecolor` | Grayscale or duotone treatment of the picture's pixels, as on an image block: recolor and opacity apply to the pixels, then the overlay is drawn above. |\n| `overlay` | no | `ref:Overlay` | Scrim over the whole picture or a band along one edge (edge, size), beneath furniture and content, for example to keep a title readable over a busy photo. |\n\n\n### ImageFocus\n\n- Type: `object`\n- Required fields: `x`, `y`\n- Purpose: A point of a picture as fractions of its width and height, from the top-left corner. A cover fit centers this point in the frame as far as the picture still covers the frame, so it always stays in view: { x: 0.5, y: 0.5 } (the default) is a center crop, { x: 0, y: 0 } keeps the top-left corner.\n\n| Field | Required | Type | Notes |\n| --- | --- | --- | --- |\n| `x` | yes | `number` | Horizontal position, 0 (left edge) to 1 (right edge). |\n| `y` | yes | `number` | Vertical position, 0 (top edge) to 1 (bottom edge). |\n\n\n### Overlay\n\n- Type: `object`\n- Required fields: `color`, `opacity`\n- Purpose: Solid scrim over a picture: the whole frame in the frame's shape, or a band along one edge. Shared by image backgrounds and image blocks. Drawn directly above its picture and beneath headings and content; PowerPoint export writes one native shape above the picture.\n\n| Field | Required | Type | Notes |\n| --- | --- | --- | --- |\n| `color` | yes | `ref:ColorRef` | Overlay fill color: a hex color (eight-digit hex keeps its alpha), a color-scheme slot or role name, or a 'var:<id>' variable reference. |\n| `opacity` | yes | `number` | Overlay fill opacity. |\n| `edge` | no | `enum:top \\| bottom \\| left \\| right` | Cover only a band along this edge of the frame, for example a caption strip. Backgrounds and rectangle image frames only; on another shape the overlay is not drawn and composition reports unsupported-image-treatment.... |\n| `size` | no | `number` | Band share of the frame for an edge overlay. Default 0.3. Ignored without edge. |\n\n\n### ImagePlacement\n\n- Type: `object`\n- Required fields: `edge`\n- Purpose: Bleeds an image block to one edge of the slide: the image takes a band along that edge, edge to edge, and the headings and other content compose in the rest of the slide at the normal padding. Only top-level blocks (slides.N.blocks.I) can be placed, at most one per edge; a layout's image placeholder may carry the same placement for the slide's image. Bands are taken in block order, each spanning the free area left by earlier ones. Placement is the only mechanism that moves content aside; a ba...\n\n| Field | Required | Type | Notes |\n| --- | --- | --- | --- |\n| `edge` | yes | `enum:left \\| right \\| top \\| bottom` | The slide edge the image bleeds to. |\n| `size` | no | `number` | Share of the slide width (left, right) or height (top, bottom) given to the band. Default 0.5. |\n| `inset` | no | `boolean` | Draw the frame inside the slide padding on every side of the band, like a card, instead of edge to edge. Default false. |\n\n\n### ImageBorder\n\n- Type: `object`\n- Required fields: `color`, `width`\n- Purpose: Solid line along the image frame's shape, centered on the outline with a miter join, like a native picture line. A thick dark border on a rounded portrait frame gives a device bezel.\n\n| Field | Required | Type | Notes |\n| --- | --- | --- | --- |\n| `color` | yes | `ref:ColorRef` | Line color: a hex color, a color-scheme slot or role name, or a 'var:<id>' variable reference. |\n| `width` | yes | `number` | Line width in reference pixels at a 720-pixel short edge. 0 removes the line. |\n\n\n### ImageRecolor\n\n- Type: `oneOf:const:\"grayscale\" / object`\n- Required fields: none\n- Purpose: Color treatment for the image pixels. Luminance uses Rec. 601 weights (0.299, 0.587, 0.114) on sRGB values.\n\n_No named properties._\n\n\n### PatternBackground\n\n- Type: `object`\n- Required fields: `type`, `pattern`\n- Purpose: Fixed pattern slide background fill.\n\n| Field | Required | Type | Notes |\n| --- | --- | --- | --- |\n| `type` | yes | `const:\"pattern\"` | Fixed pattern background fill. |\n| `pattern` | yes | `object` | Pattern fill definition. |\n| `opacity` | no | `number` | Background opacity from 0 (fully transparent) to 1 (fully opaque). |\n\n\n### LogoSet\n\n- Type: `object`\n- Required fields: none\n- Purpose: Deck logo variants surfaced by covers, section dividers, headers, footers and picture bullets. Organization identity lives in organization; this object only controls visual rendering assets. Engines select one variant per slot and background tone (resolveLogo in @openpresentation/opf): same-tone variants first, neutral ones next, the opposite tone last. Lockup on a dark background: light, default, stackedLight, stacked, wordmarkLight, wordmark, iconLight, icon, then dark, stackedDark, wordmar...\n\n| Field | Required | Type | Notes |\n| --- | --- | --- | --- |\n| `default` | no | `ref:Asset` | Default full-lockup logo. Used as fallback when no more specific variant is set. |\n| `light` | no | `ref:Asset` | Light-colored full-lockup logo intended for rendering on dark backgrounds. |\n| `dark` | no | `ref:Asset` | Dark-colored full-lockup logo intended for rendering on light backgrounds. |\n| `stacked` | no | `ref:Asset` | Stacked vertical logo lockup, suited to portrait or square brand-mark slots. |\n| `stackedLight` | no | `ref:Asset` | Light-colored stacked logo variant intended for rendering on dark backgrounds. |\n| `stackedDark` | no | `ref:Asset` | Dark-colored stacked logo variant intended for rendering on light backgrounds. |\n| `icon` | no | `ref:Asset` | Default icon, mark, or symbol without wordmark. Useful for tight spaces such as footers, badges, and slide-corner marks. |\n| `iconLight` | no | `ref:Asset` | Light-colored icon variant intended for rendering on dark backgrounds. |\n| `iconDark` | no | `ref:Asset` | Dark-colored icon variant intended for rendering on light backgrounds. |\n| `wordmark` | no | `ref:Asset` | Default wordmark: the organization name set in branded typography, without icon. |\n| `wordmarkLight` | no | `ref:Asset` | Light-colored wordmark variant intended for rendering on dark backgrounds. |\n| `wordmarkDark` | no | `ref:Asset` | Dark-colored wordmark variant intended for rendering on light backgrounds. |\n\n\n### Watermark\n\n- Type: `oneOf:schema / schema`\n- Required fields: `opacity`\n- Purpose: Decorative watermark: an image ('src') or a text stamp ('text'), exactly one of the two, with its opacity. An image is drawn once per slide, contained and centered in a fixed frame (the middle 40% of the slide width and height, from 30% to 70% on each axis). A text watermark is one line of text in the heading font and the theme text color at the given opacity, centered on the slide and rotated 30 degrees counterclockwise (rising to the right), sized to span at most 70% of the slide width and...\n\n| Field | Required | Type | Notes |\n| --- | --- | --- | --- |\n| `src` | no | `string` | Source for the watermark image: an 'asset:<id>' reference, HTTPS URL, data URI, relative path or local path, as for Asset.src. Exactly one of src and text is set. |\n| `text` | no | `string` | Text for a text watermark, such as DRAFT or CONFIDENTIAL; line breaks are drawn as spaces. Exactly one of src and text is set. |\n| `opacity` | yes | `number` | Watermark opacity from 0 (fully transparent) to 1 (fully opaque). |\n\n\n### HeaderFooter\n\n- Type: `object`\n- Required fields: none\n- Purpose: Repeated header or footer content split into left, center, and right zones. Header/footer content is slide furniture, separate from the main slide content payloads.\n\n| Field | Required | Type | Notes |\n| --- | --- | --- | --- |\n| `left` | no | `ref:HeaderFooterItem` | Left-aligned header/footer content. |\n| `center` | no | `ref:HeaderFooterItem` | Centered header/footer content. |\n| `right` | no | `ref:HeaderFooterItem` | Right-aligned header/footer content. |\n\n\n### HeaderFooterItem\n\n- Type: `object`\n- Required fields: none\n- Purpose: One header/footer zone. Every configured field renders; fields in one zone stack top to bottom in the order logo, image, text, organization, speaker, socials, section, slide number, date. Put a date and a slide number in different zones to keep each on the zone's single line.\n\n| Field | Required | Type | Notes |\n| --- | --- | --- | --- |\n| `logo` | no | `boolean` | Whether to render the deck's icon logo in this zone: a slide's design.logo, then design.logo, then the primary organization's logo (LogoSet icon variants first, light ones on dark backgrounds). It is a generated image... |\n| `text` | no | `string` | Literal text rendered in this zone. |\n| `image` | no | `ref:Asset` | Generic image rendered in this zone, such as a logo, partner mark, certification badge, or icon. |\n| `slideNumber` | no | `boolean` | Whether to render the current slide number in this zone. PPTX export writes a native slide-number field when its value fits within one accepted text line; a value split across lines exports as static text with a diagn... |\n| `slideNumberFormat` | no | `string` | Template for the slide number when slideNumber is true. {current} is the displayed slide number (a native PPTX field when its value fits within one accepted text line); {total} is the number of slides in the rendered... |\n| `date` | no | `oneOf:boolean / string` | true renders the current date: the renderer or exporter must be given an explicit ISO date by its host (core never reads a clock). PPTX export writes a native date field only for a supported dateFormat whose complete... |\n| `dateFormat` | no | `string` | Date pattern for date. Tokens: yyyy (2026), yy (26), MMMM (April), MMM (Apr), MM (04), M (4), dd (09), d (9), EEEE (Thursday), EEE (Thu). Text in single quotes and other non-letter characters are literal. Month and we... |\n| `organization` | no | `boolean` | Whether to render the primary organization's name from organization (role 'primary', else the first organization). |\n| `speaker` | no | `boolean` | Whether to render the primary (first) speaker's name and title from speaker, joined as 'Ada Lovelace, CTO' (just the name when the speaker has no title). It is generated text: without a named speaker the engine report... |\n| `section` | no | `boolean` | Whether to render the current slide section label. |\n| `socials` | no | `boolean` | Whether to render the primary organization's social profiles from organization.socials, one line per platform in key order. A handle is formatted through the platform's socialPlatforms record (companyUrlPattern, else... |\n\n\n### Slide\n\n- Type: `object`\n- Required fields: none\n- Purpose: A single slide. Content can be authored as a full-slide root payload, or inside promoted named region keys such as 'left', 'center+right', and 'top:left'.\n\n| Field | Required | Type | Notes |\n| --- | --- | --- | --- |\n| `id` | no | `string` | Optional stable identifier for the slide within the document. Use when another system needs to reference a slide across edits, comments, generation state, exports, or narrative tooling. Slide order is defined by the s... |\n| `type` | no | `enum:text \\| list \\| image \\| chart \\| table \\| video \\| code \\| metric \\| quote \\| timeline` | Optional full-slide content kind. When omitted, engines infer the kind from root payload fields. |\n| `beat` | no | `oneOf:string / array<string>` | Optional reference to one or more beats of the deck's narrative (each value is the id of a beat in the narrative record that the root 'narrative' resolves to). A single string declares the slide's primary beat; an arr... |\n| `layout` | no | `ref:CatalogReference` | Optional layout: a layouts reference, a bare id ('title-subtitle') or 'name:id' ('acme:hero'). When omitted, the slide is composed with no layout record: engines arrange title, subtitle, tag and content automatically,... |\n| `title` | no | `oneOf:string / array<ref:TextRun>` | Slide-level title content. When the resolved layout exposes a 'title' placeholder, the engine renders this value there. Use a string for plain text or TextRun[] for inline rich text (a colored word, bold, a link, a ci... |\n| `subtitle` | no | `oneOf:string / array<ref:TextRun>` | Slide-level subtitle or supporting line. When the resolved layout exposes a 'subtitle' placeholder, the engine renders this value there. Use a string for plain text or TextRun[] for inline rich text (a colored word, b... |\n| `tag` | no | `oneOf:string / array<ref:TextRun>` | Small slide-level label or badge. When the resolved layout exposes a 'tag' placeholder, the engine renders this value there. Use a string for plain text or TextRun[] for inline rich text (a colored word, bold, a link,... |\n| `text` | no | `oneOf:string / array<ref:TextRun>` | Full-slide text payload. Use a string for plain text or TextRun[] for inline rich text. TextRun items may be plain strings or formatted run objects. |\n| `items` | no | `array<ref:ListItem>` | Full-slide generic list payload. Presence of this field infers type 'list'. At slide root, multiple content payload kinds with no explicit type, blocks, or regions are accepted as shorthand for layout-agnostic blocks. |\n| `bullets` | no | `array<ref:BulletItem>` | Full-slide text-style bullet payload. Presence of this field infers type 'text'. |\n| `numbering` | no | `ref:NumberingSpec` | Number the full-slide `items` or `bullets` instead of bulleting them. A style name (arabic, roman-upper, roman-lower, alpha-upper, alpha-lower) or a Numbering object applies to every list level; an array gives one ent... |\n| `image` | no | `ref:Asset` | Shorthand for one image block: the picture composes as body content, like { type: 'image', image: value }, with the effective design.imageFit. A layout's image placeholder may place it along an edge (placement). For f... |\n| `video` | no | `ref:Asset` | Full-slide video source. Presence of this field infers type 'video'. Engines do not play video: the preview draws a placeholder (a play badge with the asset's title or source as its caption), and PPTX export draws the... |\n| `chart` | no | `ref:Chart` | Full-slide chart payload. Presence of this field infers type 'chart'. |\n| `table` | no | `ref:Table` | Full-slide table payload. Presence of this field infers type 'table'. |\n| `code` | no | `oneOf:string / ref:Code` | Full-slide code payload. A string is shorthand for { \"source\": value }; object form carries optional syntax language and filename metadata. |\n| `metric` | no | `oneOf:string / number / ref:Metric` | Full-slide metric payload. A string or number is shorthand for { \"value\": value }; object form carries optional label, description, unit, delta, trend, and sentiment metadata. Numeric values remain numbers; renderers... |\n| `quote` | no | `oneOf:string / ref:Quote` | Full-slide quote payload. A string is shorthand for { \"text\": value }; object form carries optional attribution and source metadata. Presence of this field infers type 'quote'. |\n| `timeline` | no | `ref:Timeline` | Full-slide timeline payload. An array is shorthand for { \"events\": value }; object form carries optional name and description metadata. Presence of this field infers type 'timeline'. |\n| `caption` | no | `ref:Caption` | Caption for the slide's root image, chart, table or video payload. Valid only when the slide root holds exactly one of those payloads. |\n| `blocks` | no | `array<ref:ContentPayload>` | Layout-agnostic content blocks rendered together as a composed payload when exact placement is unspecified. At slide root, multiple content payload kinds with no explicit type, blocks, or regions are accepted as short... |\n| `design` | no | `ref:SlideDesign` | Slide-level design applied on top of the deck-wide design. |\n| `left` | no | `ref:ContentPayload` | |\n| `center` | no | `ref:ContentPayload` | |\n| `right` | no | `ref:ContentPayload` | |\n| `left+center` | no | `ref:ContentPayload` | |\n| `center+right` | no | `ref:ContentPayload` | |\n| `left+center+right` | no | `ref:ContentPayload` | |\n| `top` | no | `ref:ContentPayload` | |\n| `middle` | no | `ref:ContentPayload` | |\n| `bottom` | no | `ref:ContentPayload` | |\n| `top+middle` | no | `ref:ContentPayload` | |\n| `middle+bottom` | no | `ref:ContentPayload` | |\n| `top+middle+bottom` | no | `ref:ContentPayload` | |\n| `top:left` | no | `ref:ContentPayload` | |\n| `top:center` | no | `ref:ContentPayload` | |\n| `top:right` | no | `ref:ContentPayload` | |\n| `top:left+center` | no | `ref:ContentPayload` | |\n| `top:center+right` | no | `ref:ContentPayload` | |\n| `top:left+center+right` | no | `ref:ContentPayload` | |\n| `middle:left` | no | `ref:ContentPayload` | |\n| `middle:center` | no | `ref:ContentPayload` | |\n| `middle:right` | no | `ref:ContentPayload` | |\n| `middle:left+center` | no | `ref:ContentPayload` | |\n| `middle:center+right` | no | `ref:ContentPayload` | |\n| `middle:left+center+right` | no | `ref:ContentPayload` | |\n| `bottom:left` | no | `ref:ContentPayload` | |\n| `bottom:center` | no | `ref:ContentPayload` | |\n| `bottom:right` | no | `ref:ContentPayload` | |\n| `bottom:left+center` | no | `ref:ContentPayload` | |\n| `bottom:center+right` | no | `ref:ContentPayload` | |\n| `bottom:left+center+right` | no | `ref:ContentPayload` | |\n| `top+middle:left` | no | `ref:ContentPayload` | |\n| `top+middle:center` | no | `ref:ContentPayload` | |\n| `top+middle:right` | no | `ref:ContentPayload` | |\n| `top+middle:left+center` | no | `ref:ContentPayload` | |\n| `top+middle:center+right` | no | `ref:ContentPayload` | |\n| `top+middle:left+center+right` | no | `ref:ContentPayload` | |\n| `middle+bottom:left` | no | `ref:ContentPayload` | |\n| `middle+bottom:center` | no | `ref:ContentPayload` | |\n| `middle+bottom:right` | no | `ref:ContentPayload` | |\n| `middle+bottom:left+center` | no | `ref:ContentPayload` | |\n| `middle+bottom:center+right` | no | `ref:ContentPayload` | |\n| `middle+bottom:left+center+right` | no | `ref:ContentPayload` | |\n| `top+middle+bottom:left` | no | `ref:ContentPayload` | |\n| `top+middle+bottom:center` | no | `ref:ContentPayload` | |\n| `top+middle+bottom:right` | no | `ref:ContentPayload` | |\n| `top+middle+bottom:left+center` | no | `ref:ContentPayload` | |\n| `top+middle+bottom:center+right` | no | `ref:ContentPayload` | |\n| `top+middle+bottom:left+center+right` | no | `ref:ContentPayload` | |\n| `notes` | no | `string` | Speaker notes shown in presenter view. |\n| `section` | no | `string` | PowerPoint-style slide section label. Consecutive slides with the same value belong to the same section in presenter view, outlines, and PowerPoint section-aware exports. |\n| `hidden` | no | `boolean` | Whether the slide is hidden from the presented sequence. The player skips it, the PPTX export writes it as a hidden slide, and per-slide image and PDF output skips it unless the caller asks to include hidden slides (o... |\n| `composition` | no | `ref:Composition` | |\n| `extensions` | no | `object` | Custom data passthrough for agent workflows at slide scope; ignored by the engine but preserved across read/write round-trips. Use for review state, generation provenance, or authoring conventions such as { \"authoring... |\n\n\n### ContentPayload\n\n- Type: `allOf:schema + schema + schema + schema + schema + schema + schema + schema + schema + schema + schema + schema`\n- Required fields: none\n- Purpose: A content leaf or recursively composed group. A group contains blocks and optional composition; it cannot mix blocks with leaf payload fields. Groups may nest up to 32 levels.\n\n| Field | Required | Type | Notes |\n| --- | --- | --- | --- |\n| `id` | no | `string` | Optional stable identifier for this payload, unique among slide and payload ids in the document. Use when another system needs to address the payload across edits patch-style agent edits, comments, review state, or ge... |\n| `extensions` | no | `object` | Custom data passthrough for agent workflows at payload scope; ignored by the engine but preserved across read/write round-trips. |\n| `type` | no | `enum:text \\| list \\| image \\| chart \\| table \\| video \\| code \\| metric \\| quote \\| timeline \\| group` | Optional content kind. When omitted, engines infer the kind from the fields present. |\n| `text` | no | `oneOf:string / array<ref:TextRun>` | Text payload. Use a string for plain text or TextRun[] for inline rich text. TextRun items may be plain strings or formatted run objects. |\n| `items` | no | `array<ref:ListItem>` | Generic list payload. Each item is either a plain string, a TextRun[] rich text sequence, or a ListItem object. List nesting uses item.level rather than nested content payloads. |\n| `bullets` | no | `array<ref:BulletItem>` | Text-style bullet payload. Presence of this field infers type 'text'. |\n| `numbering` | no | `ref:NumberingSpec` | Number the payload's `items` or `bullets` instead of bulleting them. A style name (arabic, roman-upper, roman-lower, alpha-upper, alpha-lower) or a Numbering object applies to every list level; an array gives one entr... |\n| `image` | no | `ref:Asset` | Source for an image item. |\n| `fit` | no | `enum:cover \\| contain \\| stretch` | Image payloads only. How the picture fills its frame: 'cover' fills it and crops what overflows around focus; 'contain' shows the whole picture centered; 'stretch' scales it to the frame exactly. Default: the effectiv... |\n| `focus` | no | `ref:ImageFocus` | Image payloads only. Point of the picture to keep in view when 'cover' crops it. Ignored by the other fits. |\n| `aspectRatio` | no | `number` | Image payloads only. Width-to-height ratio of the frame: the frame becomes the largest centered box with this ratio inside the block's region, for example 2.39 for a cinematic letterbox or 0.5 for a phone-shaped frame... |\n| `shape` | no | `enum:rectangle \\| rounded \\| circle \\| hexagon` | Image payloads only. Mask applied to the frame, exported as the picture's native preset geometry (rect, roundRect, ellipse, hexagon). 'circle' makes the frame square. Default 'rectangle'. |\n| `cornerRadius` | no | `number` | Image payloads only. Corner radius for shape 'rounded' as a fraction of the frame's shorter side. Default 0.16667, PowerPoint's roundRect default. |\n| `border` | no | `ref:ImageBorder` | Image payloads only. Line along the frame's shape. |\n| `opacity` | no | `number` | Image payloads only. Picture opacity from 0 to 1, exported as a native alphaModFix. The border and overlay keep their own opacity. Default 1. |\n| `recolor` | no | `ref:ImageRecolor` | Image payloads only. Grayscale or duotone treatment of the picture's pixels. |\n| `overlay` | no | `ref:Overlay` | Image payloads only. Scrim over the picture in the frame's shape, or a band along one edge of a rectangle frame. |\n| `placement` | no | `ref:ImagePlacement` | Image payloads only, and only on a top-level block (slides.N.blocks.I), at most one per edge. Bleeds the image to one slide edge; the headings and the other blocks compose in the rest of the slide. |\n| `video` | no | `ref:Asset` | Source for a video item. Engines do not play video: the preview draws a placeholder (a play badge with the asset's title or source as its caption), and PPTX export draws the same placeholder as native shapes linked to... |\n| `chart` | no | `ref:Chart` | Chart payload. Presence of this field infers type 'chart'. |\n| `table` | no | `ref:Table` | Table payload. Presence of this field infers type 'table'. |\n| `code` | no | `oneOf:string / ref:Code` | Code payload. A string is shorthand for { \"source\": value }; object form carries optional syntax language and filename metadata. |\n| `metric` | no | `oneOf:string / number / ref:Metric` | Metric payload. A string or number is shorthand for { \"value\": value }; object form carries optional label, description, unit, delta, trend, and sentiment metadata. Numeric values remain numbers; renderers format them... |\n| `quote` | no | `oneOf:string / ref:Quote` | Quote payload. A string is shorthand for { \"text\": value }; object form carries optional attribution, role, photo and source metadata. |\n| `timeline` | no | `ref:Timeline` | Timeline payload ordered by narrative or chronology. |\n| `caption` | no | `ref:Caption` | Caption for an image, chart, table or video payload, composed inside the block's region (below the media by default). Invalid on other payload kinds and on groups. |\n| `blocks` | no | `array<ref:ContentPayload>` | Ordered children of a group. Each child is a leaf or another group. |\n| `composition` | no | `ref:Composition` | Arrangement within this group. Only minFontSize and overflow inherit from the parent; strict overflow cannot be weakened. |\n\n\n### Quote\n\n- Type: `object`\n- Required fields: `text`\n- Purpose: Quote content with optional attribution metadata. Use 'text' for the quoted text, 'attribution' for the credited person or organization, 'role' for that person's title and organization, 'photo' for their headshot and 'source' for a citation or URL. A string value in a quote field is shorthand for { \"text\": value }.\n\n| Field | Required | Type | Notes |\n| --- | --- | --- | --- |\n| `text` | yes | `oneOf:string / array<ref:TextRun>` | Quoted text: a string, or TextRun[] for inline rich text (a colored phrase, bold, a link, or a citation or footnote marker). The engine draws the surrounding quotation marks. |\n| `attribution` | no | `string` | Person or organization credited for the quote. Drawn after the quote text, in the muted text color. |\n| `role` | no | `string` | Title and organization of the attributed person. Drawn on its own line under the attribution; without an attribution it stands alone. It does not replace the attribution, so the person's name belongs in 'attribution'. |\n| `photo` | no | `ref:Asset` | Headshot of the attributed person, drawn as a circle beside the attribution and role lines (on the end side in a right-to-left deck). Give it alt text (validate's opf/missing-alt-text rule checks it). A raster photogr... |\n| `source` | no | `string` | Optional quote source, citation, or URL. Follows the attribution and role after ' - '. |\n\n\n### Code\n\n- Type: `object`\n- Required fields: `source`\n- Purpose: Code content with optional rendering metadata. Use 'source' for the code text, 'language' for syntax highlighting, 'filename' when the rendered block should show a file label, and 'highlight' to emphasize lines. A string value in a code field is shorthand for { \"source\": value }.\n\n| Field | Required | Type | Notes |\n| --- | --- | --- | --- |\n| `source` | yes | `string` | Source code text to display. |\n| `language` | no | `string` | Language identifier used for syntax highlighting. |\n| `filename` | no | `string` | Optional file label shown with the code block. |\n| `highlight` | no | `array<oneOf:integer / array<integer>>` | Source lines to emphasize, 1-based: each entry is a line number or an inclusive [start, end] range, so [3, [5, 7]] marks lines 3 and 5 to 7. Lines are counted by line break in 'source', so a line that wraps stays one... |\n\n\n### Metric\n\n- Type: `object`\n- Required fields: `value`\n- Purpose: Metric content with optional display metadata. Use 'value' for the primary value, 'label' for the metric name, 'description' for supporting context, 'unit' for a suffix/currency marker, 'delta' for change, 'trend' for direction, and 'sentiment' for whether the change is good news. A string or number value in a metric field is shorthand for { \"value\": value }; numeric values remain numbers and are formatted by renderers.\n\n| Field | Required | Type | Notes |\n| --- | --- | --- | --- |\n| `value` | yes | `oneOf:string / number` | Primary metric value. |\n| `label` | no | `string` | Metric label. |\n| `description` | no | `string` | Optional supporting context for the metric. |\n| `unit` | no | `string` | Metric unit, suffix, or currency marker. |\n| `delta` | no | `oneOf:string / number` | Metric change value. |\n| `trend` | no | `enum:up \\| down \\| flat` | Metric trend direction. The preview and the PowerPoint export draw an arrow beside the trend word that always points the way the trend does (up, down or flat) and colour the arrow, the trend word and the delta text by... |\n| `sentiment` | no | `enum:positive \\| negative \\| neutral` | Whether the change is good news. Positive draws the trend arrow and the trend and delta text in green, negative in red and neutral in the neutral text colour, each kept at 4.5:1 contrast or more against the slide back... |\n\n\n### Timeline\n\n- Type: `oneOf:array<ref:TimelineEvent> / object`\n- Required fields: none\n- Purpose: Timeline content. An array is shorthand for { \"events\": value }; object form carries optional name and description metadata.\n\n_No named properties._\n\n\n### TimelineEvent\n\n- Type: `object`\n- Required fields: `what`\n- Purpose: A single event inside a timeline content payload.\n\n| Field | Required | Type | Notes |\n| --- | --- | --- | --- |\n| `when` | no | `string` | Event time, date, or sequence label. Use ISO-like values when possible, but human labels are allowed for quarters, eras, and relative milestones. |\n| `what` | yes | `string` | Short event label. |\n| `description` | no | `string` | Optional event detail. |\n| `status` | no | `enum:done \\| current \\| planned` | Progress of the event, drawn from the deck's own colors by both the SVG renderer and the PPTX exporter. Absent means no status and the event is drawn as a plain filled marker with normal text, which is also how 'done'... |\n\n\n### ListItem\n\n- Type: `oneOf:string / array<ref:TextRun> / object`\n- Required fields: none\n- Purpose: A flat item inside a list. Strings cover the common case, TextRun[] supports inline rich text without an object wrapper, and object form adds description and nesting depth without creating nested slide content payloads.\n\n_No named properties._\n\n\n### BulletItem\n\n- Type: `oneOf:string / array<ref:TextRun> / object`\n- Required fields: none\n- Purpose: A flat bullet item. Strings cover the common case, TextRun[] supports inline rich text without an object wrapper, and object form adds nesting depth without list-item descriptions.\n\n_No named properties._\n\n\n### NumberingStyle\n\n- Type: `enum:arabic | roman-upper | roman-lower | alpha-upper | alpha-lower`\n- Required fields: none\n- Purpose: A list number style: 1, 2, 3; I, II, III; i, ii, iii; A, B, C; a, b, c. Alphabetic numbering past 26 repeats the letter as PowerPoint does (aa, bb, cc). Roman numerals stop at 3999; larger values are drawn in arabic with a numbering-adapted diagnostic.\n\n_No named properties._\n\n\n### Numbering\n\n- Type: `object`\n- Required fields: none\n- Purpose: Numbering of one list level.\n\n| Field | Required | Type | Notes |\n| --- | --- | --- | --- |\n| `style` | no | `ref:NumberingStyle` | Number style. Default arabic. |\n| `start` | no | `integer` | First number counted at this level. Default 1. Native PowerPoint accepts 1 to 32767. |\n| `suffix` | no | `enum:period \\| paren \\| paren-both` | Text after the number: period (1.), paren (1)) or paren-both ((1)). Default period. |\n\n\n### NumberingSpec\n\n- Type: `oneOf:ref:NumberingStyle / ref:Numbering / array<oneOf:ref:NumberingStyle / ref:Numbering>`\n- Required fields: none\n- Purpose: The value of a numbering field: a style name or Numbering object for every level, or an array with one entry per level (at most 9, the native depth).\n\n_No named properties._\n\n\n### TextRun\n\n- Type: `oneOf:string / object`\n- Required fields: none\n- Purpose: A contiguous run of text. Strings cover unformatted spans; object form adds character formatting.\n\n_No named properties._\n\n\n### Caption\n\n- Type: `oneOf:string / array<ref:TextRun> / object`\n- Required fields: none\n- Purpose: A caption for an image, chart, table or video payload. A string or TextRun[] is the caption text placed below the media; object form adds the position and alignment.\n\n_No named properties._\n\n\n### Reference\n\n- Type: `object`\n- Required fields: `id`, `text`\n- Purpose: A source that runs cite with 'cite'. Cited references are listed in the footnote area of the slides that cite them, numbered per deck in order of first use; referencesSlide() builds an ordinary list slide of them.\n\n| Field | Required | Type | Notes |\n| --- | --- | --- | --- |\n| `id` | yes | `string` | Identifier runs cite. Unique within the references list. |\n| `text` | yes | `oneOf:string / array<ref:TextRun>` | The reference as it is listed: a string or TextRun[] for inline rich text. |\n| `url` | no | `string` | Optional link for the reference; a references slide links its entry to it. |\n\n\n### Chart\n\n- Type: `object`\n- Required fields: `type`, `data`\n- Purpose: Chart content. The chart object keeps chart-specific fields together so slides and regions do not expose loose chart fields.\n\n| Field | Required | Type | Notes |\n| --- | --- | --- | --- |\n| `type` | yes | `enum:100pct-stacked-area \\| 100pct-stacked-bar \\| 100pct-stacked-column \\| area \\| bar \\| box-and-whisker \\| column \\| combo \\| doughnut \\| filled-radar \\| funnel \\| histogram \\| line \\| line-with-markers \\| pareto \\| pie \\| radar \\| radar-with-markers \\| scatter \\| stacked-area \\| stacked-bar \\| stacked-column \\| stacked-line \\| stacked-line-with-markers \\| treemap \\| waterfall \\| world` | Chart type, an engine vocabulary validated directly (no catalog lookup): the chart types Aspose.Slides officially supports, plus 'combo' (clustered columns with line series, see 'line' and 'secondaryAxis'). Each engin... |\n| `data` | yes | `oneOf:ref:ChartData / ref:DatasetRef` | Chart data. Inline data uses a tabular columns/rows shape; renderers convert rows to chart series internally. A DatasetRef ({ \"dataset\": \"<id>\" }) plots a top-level dataset instead. Data from a spreadsheet or file is... |\n| `mapping` | no | `ref:ChartMapping` | Optional series mapping by column name: which column is the category, which is the X column of a scatter chart, and which columns are plotted, in order. Absent keeps the positional rule (first column the category, the... |\n| `line` | no | `array<string>` | Combo charts only (type 'combo'): the plotted series, by column name, that are drawn as lines with markers; every other plotted series is drawn as clustered columns. Absent: the last plotted series is the line. A comb... |\n| `secondaryAxis` | no | `array<string>` | Combo charts only: line series, by column name, plotted against a secondary value axis at the right of the plot. That axis has its own scale, and its tick labels use the number format of the first secondary series' co... |\n| `axisTitles` | no | `ref:ChartAxisTitles` | Optional axis titles. Absent keeps today's untitled axes. Supported on the chart types that have a category/value (or X/Y) axis pair (column, bar, line, area, scatter, and the histogram, pareto, waterfall and box-and-... |\n| `legend` | no | `enum:none \\| top \\| bottom \\| left \\| right` | Optional legend position. 'none' hides the legend. Absent keeps today's behaviour exactly (a legend at the right of multi-series charts and of pie and doughnut charts, none for single-series charts). A named position... |\n| `dataLabels` | no | `oneOf:boolean / ref:ChartDataLabels` | Optional data labels. true shows value labels at each type's default position, false (or absent) shows none, which is today's behaviour. Use the object form for the label content, position and separator. |\n| `alt` | no | `string` | Text alternative for the chart: what the data shows (the point and the key numbers), not 'a chart' or 'bar chart'. The preview exposes it as the chart's accessible name (role img with aria-label) and the PowerPoint ex... |\n| `highlight` | no | `ref:ChartHighlight` | Optional emphasis: the series and/or categories that carry the message. The engines draw the highlighted marks in the deck's primary (accent) color and every other mark in a muted neutral derived from the theme (a lig... |\n\n\n### ChartAxisTitles\n\n- Type: `object`\n- Required fields: none\n- Purpose: Titles for the axes of a chart. 'category' is the axis that carries the row labels (the horizontal axis of a column or line chart, the vertical axis of a bar chart, the X axis of a scatter chart); 'value' is the other axis; 'secondary' is the secondary value axis of a combo chart.\n\n| Field | Required | Type | Notes |\n| --- | --- | --- | --- |\n| `category` | no | `string` | Title of the category (X) axis. |\n| `value` | no | `string` | Title of the value (Y) axis. On a combo chart, the primary (left) value axis. |\n| `secondary` | no | `string` | Combo charts only: title of the secondary value axis at the right of the plot, drawn rotated like the primary value axis title. Dropped with a 'chart-option-adapted' diagnostic on any other chart type and on a combo c... |\n\n\n### ChartHighlight\n\n- Type: `object`\n- Required fields: none\n- Purpose: Which series and categories a chart emphasizes. Names are matched exactly: series by data column name (after any 'fields' selection; a series is a plotted value column), categories by row label (the first column, or mapping.category), as text. A category label shared by several rows highlights each of them.\n\n| Field | Required | Type | Notes |\n| --- | --- | --- | --- |\n| `series` | no | `array<string>` | Series (value columns) to highlight: all marks of each named series. A name that is not a column is a 'chart-highlight-unknown-name' error; a column that is not plotted as a series (the category or X column, or one ma... |\n| `categories` | no | `array<string>` | Categories (row labels) to highlight: the marks of each named category (a column or bar of every series, a line's points, a pie or doughnut slice). A label no row has is a 'chart-highlight-unknown-name' error. |\n\n\n### ChartDataLabels\n\n- Type: `object`\n- Required fields: none\n- Purpose: Data label settings. A label shows the selected content parts in the fixed order category, value, percent, joined by the separator.\n\n| Field | Required | Type | Notes |\n| --- | --- | --- | --- |\n| `content` | no | `array<enum:value \\| percent \\| category>` | Which parts a label shows. Defaults to ['value']. 'percent' is the share of the total and exists only on pie and doughnut charts; 'category' shows the category name (the X value on a scatter chart). A part a chart typ... |\n| `position` | no | `enum:auto \\| center \\| inside-end \\| inside-base \\| outside-end \\| above \\| below \\| left \\| right` | Where a label sits relative to its mark. 'auto' (the default) is the type's default: outside-end for clustered columns and bars, pie slices and the histogram, pareto and waterfall constructs; center for stacked column... |\n| `separator` | no | `string` | Text between the parts of a label that shows more than one. Defaults to ', '. |\n\n\n### Table\n\n- Type: `object`\n- Required fields: none\n- Purpose: Table content, inline or dataset-backed. An inline table has 'rows' (required) and optional 'columns'. A dataset-backed table has 'dataset' (required) and optional 'fields', and no 'rows' or 'columns': it takes its headers, rows and column formats from the dataset (per-cell styles need an inline table).\n- Conditional requirement: `dataset`, or otherwise `rows`\n\n| Field | Required | Type | Notes |\n| --- | --- | --- | --- |\n| `columns` | no | `array<oneOf:string / array<ref:TextRun> / ref:StyledTableCell / ref:DataColumn / null>` | Optional column labels. Labels may be strings, rich runs, styled cell objects or DataColumn objects ({ \"name\", \"format\" }). A header's 'format' (DataColumn or StyledTableCell) is the column's number format; a body cel... |\n| `rows` | no | `array<array<ref:TableCell>>` | Two-dimensional table row data; each row aligns by index with columns when columns are supplied. |\n| `dataset` | no | `ref:DatasetId` | Id of a top-level dataset that supplies this table's headers, rows and column formats. Excludes 'rows' and 'columns'. An unknown id is a 'dataset-unknown' error. |\n| `fields` | no | `ref:DatasetFields` | Dataset tables only: the dataset columns to show, by name and in order. Absent shows every column. An unknown name is a 'dataset-field-unknown' error. |\n\n\n### ChartData\n\n- Type: `object`\n- Required fields: `columns`, `rows`\n- Purpose: Inline tabular data driving a chart. The first column usually supplies category/x-axis labels; subsequent columns are plotted measures unless 'chart.mapping' or a chart type maps them differently. Value cells are numbers; a string is read only in strict decimal syntax ('12', '-3.5', '1e6'), and anything else ('12%', '$5', '(5)', '1,234') is a gap and a 'chart-value-not-numeric' warning.\n\n| Field | Required | Type | Notes |\n| --- | --- | --- | --- |\n| `columns` | yes | `array<oneOf:string / ref:DataColumn>` | Ordered column labels for the chart data table. A label is a string or a DataColumn ({ \"name\", \"format\" }) whose number format the data labels, value axis and exported workbook use. |\n| `rows` | yes | `array<array<ref:ChartDataCell>>` | Tabular chart rows. Each row aligns by index with columns. |\n| `source` | no | `ref:DataSourceRef` | Optional provenance: where this inline data came from. Engines never read, fetch or refresh it; they keep it through editing, export and re-import. |\n\n\n### DataColumn\n\n- Type: `object`\n- Required fields: `name`\n- Purpose: A named data column with an optional number format. Anywhere a chart or dataset column is a string it may be a DataColumn; the string form is { \"name\": value } with no format. A table column header may also be a DataColumn.\n\n| Field | Required | Type | Notes |\n| --- | --- | --- | --- |\n| `name` | yes | `string` | Column label, used as the series name, the table header and the name 'fields' and 'chart.mapping' address. |\n| `format` | no | `ref:NumberFormat` | Number format of the column's number values (data labels, value axis, table cells, exported workbook). Absent: the General form. |\n\n\n### DataSourceRef\n\n- Type: `object`\n- Required fields: `src`\n- Purpose: Provenance of inline data or a dataset: the file, sheet and range it came from and when. Engines never read, fetch or refresh it; they keep it through editing, export and re-import. Re-import the data to update it.\n\n| Field | Required | Type | Notes |\n| --- | --- | --- | --- |\n| `src` | yes | `string` | Where the data came from: an 'asset:<id>' reference, a URL, or a relative or local path. |\n| `sheet` | no | `string` | Sheet or table name inside a spreadsheet source. |\n| `range` | no | `string` | A1-style range inside a spreadsheet source. |\n| `fields` | no | `array<string>` | The source fields the data was taken from, in order. |\n| `retrieved` | no | `anyOf:schema / schema` | When the data was taken from the source: an ISO 8601 date or date-time. |\n| `description` | no | `string` | Free-form note about the source. |\n\n\n### DatasetId\n\n- Type: `string`\n- Required fields: none\n- Purpose: Id of an entry in the top-level datasets map (the assets id pattern).\n\n_No named properties._\n\n\n### DatasetFields\n\n- Type: `array<string>`\n- Required fields: none\n- Purpose: Dataset column names to use, in order, each at most once. Selects and orders the dataset's columns; an unknown name is a 'dataset-field-unknown' error.\n\n_No named properties._\n\n\n### Datasets\n\n- Type: `object`\n- Required fields: none\n- Purpose: Shared data tables keyed by id (the assets id pattern). Charts reference one with 'chart.data': { \"dataset\": \"<id>\" } and tables with 'table': { \"dataset\": \"<id>\" }.\n\n_No named properties._\n\n\n### Dataset\n\n- Type: `object`\n- Required fields: `columns`, `rows`\n- Purpose: One shared data table: named columns (strings or DataColumn objects with a number format) and rows of scalar cells. Column names are unique ('data-column-duplicate' error). Rich text and styling belong in an inline table.\n\n| Field | Required | Type | Notes |\n| --- | --- | --- | --- |\n| `title` | no | `string` | Human name of the dataset, shown by editors. |\n| `description` | no | `string` | What the dataset holds. |\n| `columns` | yes | `array<oneOf:string / ref:DataColumn>` | Ordered, uniquely named columns. |\n| `rows` | yes | `array<array<ref:ChartDataCell>>` | Rows of scalar cells (string, number, boolean, null); each row aligns by index with columns. |\n| `source` | no | `ref:DataSourceRef` | Optional provenance. Engines never read, fetch or refresh it. |\n\n\n### DatasetRef\n\n- Type: `object`\n- Required fields: `dataset`\n- Purpose: Chart data taken from a top-level dataset. Engines inline it before plotting; 'chart.mapping' names columns after the 'fields' selection.\n\n| Field | Required | Type | Notes |\n| --- | --- | --- | --- |\n| `dataset` | yes | `ref:DatasetId` | Id of the dataset. An unknown id is a 'dataset-unknown' error. |\n| `fields` | no | `ref:DatasetFields` | The dataset columns to use, by name and in order. Absent uses every column. |\n\n\n### ChartMapping\n\n- Type: `object`\n- Required fields: none\n- Purpose: Series mapping by column name (after any 'fields' selection). Mapping only selects and orders columns; pie, doughnut and other single-series constructs still plot one series. An unknown name is a 'chart-mapping-unknown-column' error; a series that repeats the category or X column, and an X column on a chart type without an X axis, are dropped with a 'chart-mapping-adapted' warning.\n\n| Field | Required | Type | Notes |\n| --- | --- | --- | --- |\n| `category` | no | `string` | The label (category) column. Default: the first column. |\n| `x` | no | `string` | The X column of an XY (scatter) chart. Default: the second column (the first, when the category is the second column), and only when there are three or more columns: with two, the second column is the one series, plot... |\n| `series` | no | `array<string>` | The plotted columns, in order. Default: every column that is not the category or the X column. |\n\n\n### ChartDataCell\n\n- Type: `oneOf:string / number / boolean / null`\n- Required fields: none\n- Purpose: A cell in inline chart data.\n\n_No named properties._\n\n\n### TableCell\n\n- Type: `oneOf:ref:TableCellValue / ref:StyledTableCell`\n- Required fields: none\n- Purpose: A scalar, rich-run array, or styled/spanning cell object. Existing scalar and rich forms remain valid.\n\n_No named properties._\n\n\n### TableCellValue\n\n- Type: `oneOf:string / number / boolean / null / array<ref:TextRun>`\n- Required fields: none\n- Purpose: A scalar table value or canonical rich text runs, without cell decoration or geometry.\n\n_No named properties._\n\n\n### StyledTableCell\n\n- Type: `object`\n- Required fields: `value`\n- Purpose: A cell with explicit visual style or merged geometry. Its position remains its array column index; use null placeholders for every covered grid position.\n\n| Field | Required | Type | Notes |\n| --- | --- | --- | --- |\n| `value` | yes | `ref:TableCellValue` | Editable cell content; styling and spans do not change its scalar type or rich runs. |\n| `style` | no | `ref:TableCellStyle` | |\n| `colSpan` | no | `integer` | Number of grid columns covered, starting at this cell. Covered positions must contain null. Default 1. |\n| `rowSpan` | no | `integer` | Number of grid rows covered, starting at this cell. Covered positions must contain null. Header cells cannot span into body rows. Default 1. |\n| `format` | no | `ref:NumberFormat` | Number format. On a body cell it formats that cell's number value (and wins over the column's format); on a header cell (in 'columns') it is the column's format, the same as a DataColumn header. Strings, booleans, nul... |\n\n\n### TableCellStyle\n\n- Type: `object`\n- Required fields: none\n- Purpose: Cell appearance. Sizes use reference pixels at a 720-pixel canvas short edge and scale with the slide.\n\n| Field | Required | Type | Notes |\n| --- | --- | --- | --- |\n| `fill` | no | `ref:ColorRef` | Cell background: a hex color, a color-scheme slot or role name, or a 'var:<id>' variable reference. Eight-digit hex colors include alpha; #00000000 is transparent. |\n| `color` | no | `ref:ColorRef` | Default text color, overridden by individual rich run colors. Accepts a hex color, a color-scheme slot or role name, or a 'var:<id>' variable reference. |\n| `align` | no | `enum:left \\| center \\| right` | Horizontal text alignment inside the cell. |\n| `verticalAlign` | no | `enum:top \\| middle \\| bottom` | Vertical alignment inside the padded cell box. |\n| `padding` | no | `ref:TableCellPadding` | |\n| `borders` | no | `object` | Independent cell edges. Omitted edges retain the table theme border; width 0 removes an edge. |\n\n\n### TableCellPadding\n\n- Type: `object`\n- Required fields: none\n- Purpose: Text insets in reference pixels. Defaults: top 8, right 10, bottom 4, left 10.\n\n| Field | Required | Type | Notes |\n| --- | --- | --- | --- |\n| `top` | no | `number` | |\n| `right` | no | `number` | |\n| `bottom` | no | `number` | |\n| `left` | no | `number` | |\n\n\n### TableCellBorder\n\n- Type: `object`\n- Required fields: `color`, `width`\n- Purpose: One explicit cell border.\n\n| Field | Required | Type | Notes |\n| --- | --- | --- | --- |\n| `color` | yes | `ref:ColorRef` | Border color: a hex color, a color-scheme slot or role name, or a 'var:<id>' variable reference. Eight-digit hex colors include alpha; #00000000 is transparent. |\n| `width` | yes | `number` | Border width in reference pixels; 0 removes this edge. |\n| `dash` | no | `enum:solid \\| dash \\| dot` | Default solid. |\n\n\n### Catalogs\n\n- Type: `object`\n- Required fields: none\n- Purpose: The catalog records a document embeds, grouped by the catalog they came from. Each property is a group; inside a group, records are keyed by kind and then by id. - 'default': the catalog bare ids come from. It needs a 'source' when it holds records. Omitted: bare ids fall back to the host's default catalog. false: no catalog fallback, so every bare id must be embedded (under 'custom' or 'default'). - 'custom': the records the document defines itself. It has no source. - any other name ('acme'...\n\n| Field | Required | Type | Notes |\n| --- | --- | --- | --- |\n| `default` | no | `oneOf:allOf:ref:CatalogGroup + schema / const:false` | The catalog bare ids come from: its source and the records of it this document embeds. false turns off the catalog fallback for bare ids. |\n| `custom` | no | `allOf:ref:CatalogGroup + schema` | Records the document defines itself, referenced by bare id. Checked first for every bare id. Forking a catalog record copies it here under a new id, so the change of ownership is visible. |\n\n\n### CatalogGroup\n\n- Type: `object`\n- Required fields: none\n- Purpose: One catalog group: the catalog's source and the records of it the document embeds, by kind and then by id.\n\n| Field | Required | Type | Notes |\n| --- | --- | --- | --- |\n| `source` | no | `ref:CatalogSource` | |\n| `layouts` | no | `ref:CatalogRecords` | Layout records keyed by id, each with the fields of https://openpresentation.org/schema/opf-layout/v1. Referenced from Slide.layout. |\n| `themes` | no | `ref:CatalogRecords` | Theme records keyed by id, each with the fields of https://openpresentation.org/schema/opf-theme/v1. Referenced from design.theme. |\n| `colorSchemes` | no | `ref:CatalogRecords` | Colour-scheme records keyed by id, each with the fields of https://openpresentation.org/schema/opf-color-scheme/v1. Referenced from design.colorScheme and a theme's colorScheme. |\n| `fontSchemes` | no | `ref:CatalogRecords` | Font-scheme records keyed by id, each with the fields of https://openpresentation.org/schema/opf-font-scheme/v1. Referenced from design.fontScheme, a theme's fontScheme and a language's font schemes. |\n| `narratives` | no | `ref:CatalogRecords` | Narrative records keyed by id, each with the fields of https://openpresentation.org/schema/opf-narrative/v1. Referenced from narrative. |\n| `audiences` | no | `ref:CatalogRecords` | Audience records keyed by id, each with the fields of https://openpresentation.org/schema/opf-audience/v1. Referenced from audience. |\n| `purposes` | no | `ref:CatalogRecords` | Purpose records keyed by id, each with the fields of https://openpresentation.org/schema/opf-purpose/v1. Referenced from purpose. |\n| `tones` | no | `ref:CatalogRecords` | Tone records keyed by id, each with the fields of https://openpresentation.org/schema/opf-tone/v1. Referenced from tone. |\n\n\n### CatalogSource\n\n- Type: `string`\n- Required fields: none\n- Purpose: Identity of a catalog: an HTTPS URL ('https://www.pptx.gallery') or a package reference ('pkg:@acme/opf-catalog'). It names the catalog; engines never fetch or install it. A host registers the catalog's records under this exact string, and copying slides between documents matches groups by it.\n\n_No named properties._\n\n\n### CatalogRecords\n\n- Type: `object`\n- Required fields: none\n- Purpose: Embedded records of one kind, keyed by id. The key is the record's id; an embedded record carries no '$schema', 'id', origin or digest. Catalog display metadata ('x-*' members) is stripped when a record is embedded.\n\n_No named properties._\n\n\n### CatalogReference\n\n- Type: `string`\n- Required fields: none\n- Purpose: A content reference: a bare id ('two-column') or 'name:id' ('acme:hero'), where the prefix names a group of 'catalogs'. URLs and 'pkg:' strings are not references; a named catalog group replaces them. See Catalogs for the resolution order.\n\n_No named properties._\n\n\n### LanguageTag\n\n- Type: `string`\n- Required fields: none\n- Purpose: A BCP-47 language tag ('en-US', 'ja', 'zh-Hans', 'sr-Latn-ME'), an engine vocabulary. Use 'en-GB' for UK English; 'en-UK' is not a valid region.\n\n_No named properties._\n\n\n### Composition\n\n- Type: `object`\n- Required fields: none\n- Purpose: Portable dynamic composition. Slide fields override the resolved layout. Nested groups arrange their children independently, inheriting only minFontSize and overflow. Explicit promoted regions retain their positions.\n\n| Field | Required | Type | Notes |\n| --- | --- | --- | --- |\n| `mode` | no | `enum:auto \\| grid \\| row \\| column` | auto chooses a grid from available space and content; grid uses columns; row and column use one horizontal or vertical track. |\n| `columns` | no | `integer` | Column count for grid. In auto mode this caps the number of columns. |\n| `gap` | no | `number` | Space between cells as a fraction of the container short edge (canvas at slide root). Default 0.03333333333333333. |\n| `padding` | no | `number` | Inset as a fraction of the container short edge. Default 0.08 on a slide, 0 inside a group. |\n| `weights` | no | `array<number>` | Relative track sizes: columns for row/grid/auto, rows for column. Omitted tracks have weight 1; extra weights are ignored. |\n| `minFontSize` | no | `number` | Minimum readable text size in reference pixels at a 720-pixel canvas short edge. Default 16. Overflow is diagnosed when text cannot fit at this size. |\n| `overflow` | no | `enum:warn \\| error` | warn returns diagnostics for content that does not fit; error rejects layout. Content is never silently removed. Default warn. |\n"
284
284
  },
285
285
  {
286
286
  "slug": "security-2026-09-09",
@@ -292,7 +292,7 @@ var docsData = Object.freeze([
292
292
  "slug": "stats",
293
293
  "file": "docs/stats.md",
294
294
  "title": "Stats",
295
- "markdown": "# Stats\n\n`stats(presentation, options?)` and `opf stats <file|->` report neutral facts about a deck: what is in it and how much. Counts and lists only. There are no severities, no judgments and no thresholds, so nothing here says a deck is too long, too dense or missing something; `opf validate` says what is wrong, and a reviewer decides what is good.\n\n```js\nimport { stats } from '@openpresentation/opf';\n\nconst facts = stats(deck);\nfacts.slides.total; // 12\nfacts.words.content; // 842 words on the slides\nfacts.notes.withoutNotes; // [{ index: 3, id: 'pricing' }, ...]\nfacts.images.content.missingAlt; // 2\nfacts.speakingTime; // { basis: 'estimate', wordsPerMinute: 130, notesWords: 410, minutes: 3.2, declaredMinutes: 20 }\nstats(deck, { perSlide: true }).perSlide; // one row per slide\n```\n\n```sh\nopf stats deck.opf.json # JSON\nopf stats deck.opf.json --format text # one readable block per topic\nopf stats - --per-slide < deck.opf.json # adds a row per slide\n```\n\n## What it does and does not do\n\n- **Structural only.** It walks the JSON. It does not compose slides, build layouts, measure text or need fonts, and it does not validate: a deck that fails `opf validate` still has facts, and an unknown id is reported as written. It reads what is there and never throws on a malformed deck (it throws only for an argument that is not an object).\n- **Cheap.** Measured on the 126 bundled example decks, warm, with the per-slide breakdown: 18 ms for all of them (0.14 ms mean), against 46 ms for `validate(deck, { only: ['format'] })`, 290 ms for `only: ['format', 'references']` and 1.1 s for a full `validate` over the same decks (one machine; the ratios hold). Template variables are read with `listVariables`; nothing else loads a catalog except the theme and font-scheme records that name a size and families.\n- **Deterministic.** The same deck gives the same output with the same key order: objects are built in a fixed order, and every count map and id list is sorted (slide lists keep deck order). Nothing reads a clock, a locale or the runtime's Unicode word-break data.\n- **Pure.** The input is not changed.\n\n`stats(presentation, { perSlide?, values?, wordsPerMinute? })`: `perSlide` adds `perSlide`, `values` is a variables map to count as filled (as for `resolveVariables`), `wordsPerMinute` sets the speaking pace of the estimate (default 130). The root also exports the result types (`PresentationStats`, `SlideStats`, ...).\n\n## The report\n\n| Section | Facts |\n| --- | --- |\n| `deck` | `name`, `description` (is there one), `filename`, `author`, `language`, `slideSize` (`preset`, `widthInches`, `heightInches`, `aspectRatio`, and `source`: the deck's `design.dimensions`, its theme, or the default), `template`, `tags`, `takeaways` (how many), `declaredMinutes`, `design` (`theme`, `colorScheme`, `fontScheme`), `audience`, `purpose`, `tone`, `narrative` (with its `beats` count). A reference is `{ id, inline }`: the string as written (a catalog id, URL, `pkg:` reference or free text) or an inline record's `id`, with `inline: true` for the object form. |\n| `people` | `organizations` (`id`, `name`, `role`, `hasLogo`) and `speakers` (`id`, `name`, `title`, `organizationId`, `hasPhoto`). |\n| `slides` | `total`; `hidden` (`[{ index, id }]`, where `index` is the zero-based position, the same as `/slides/<index>`); `withTitle`, `withoutTitle`; `withBeat`; `sections` (consecutive slides with the same `section` label: `name`, `slides`, `firstSlide`) and `unsectioned`; `layouts` (slides per layout id) and `withoutLayout` (layout left to inference); `withOwnDesign` (slides whose `design` overrides the deck). |\n| `payloads` | Content payloads by kind across slide roots, blocks and promoted regions: `text`, `items`, `bullets`, `quote`, `metric`, `code`, `timeline`, `chart`, `table`, `image`, `video`; plus `blocks` (block entries at any depth), `regions` (promoted regions such as `left` or `top:center`), `groups` (blocks and regions that hold nested `blocks`) and `maxDepth` (deepest block nesting). |\n| `words` | `content`, `notes`, `total`, and `perSlide` (`min`, `max`, `mean` of content words). |\n| `notes` | `withNotes` and `withoutNotes` (`[{ index, id }]`). A note of only whitespace is no note. |\n| `speakingTime` | `basis: 'estimate'`, `wordsPerMinute`, `notesWords`, `minutes` (notes words divided by the pace, to a tenth), `declaredMinutes` (the deck's own `duration`). |\n| `images` | `content` (`total`, `withAlt`, `decorative`, `missingAlt`), `logos`, `watermarks` (`deck`, `slides`), `backgrounds` (`deck`, `slides`), `headerFooter`, `speakerPhotos`, `videos`. |\n| `charts` | `total`, `byType`, and `data`: how many take `inline` data, a `dataset` reference or an external `source`. |\n| `tables` | `total`, `datasetBacked`, total `rows` and `columns`, and `items` (`slide`, `path`, `rows`, `columns`, `dataset`). |\n| `datasets` | `count`, `withSource`, and `items` (`id`, `title`, `columns`, `rows`, `source`: the declared `source.src` or null, `referencedBy`: charts and tables that use it). |\n| `citations` | `references`, `cited` (reference ids some run cites), `citations` (cited runs), `footnotes`, `captions`, `links`. |\n| `variables` | `declared`, `byKind`, `required`, `optional`, `filled`, `unfilled`, `unfilledRequired`, `unused` (declared, never referenced). |\n| `assets` | `registry` (`entries`, `embedded`, `files`, `remote`), `uses` (every asset-bearing field: `total`, `references`, `embedded`, `files`, `remote`) and `embeddedBytes`. |\n| `headerFooter` | `header` and `footer` at deck level (`configured`, `suppressed`, `zones`, `fields`) and `slides` (`headerOverrides`, `headerSuppressed`, `footerOverrides`, `footerSuppressed`). |\n| `fonts` | `families` (every family named), `schemeIds` (font schemes in effect), `unresolvedSchemeIds`, `runOverrides` (families set on text runs). |\n| `colors` | `variables`: each colour variable with its `value` and number of `uses`. |\n| `extensions`, `catalogOverrides` | The keys under `extensions` anywhere; per-kind inline catalog `records` and `source`. |\n| `perSlide` | With `perSlide: true`: per slide `index`, `id`, `title`, `layout`, `section`, `hidden`, `payloads` (kinds present), `words` (`content`, `notes`), `hasNotes`, `speakingSeconds`, `images`, `charts`, `tables`, `citations`. |\n\n## Definitions\n\n- **Words.** Letters and digits in a run, with an internal apostrophe, hyphen, comma or period joining parts (`don't`, `well-known`, `3.5`, `$4.2M` is one word). Thai, Lao, Myanmar, Khmer, kana and Han text, which has no spaces between words, counts one unit per character: a fixed rule, so a count never depends on the runtime's Unicode data. **Content words** are the visible text on slides: `title`, `subtitle`, `tag`, text, items (and their descriptions), bullets, quote text, attribution and source, metric fields, timeline fields, table headers and cells (a number is one word), and captions. Code, chart data, header and footer text and reference texts are not counted. A dataset-backed table has no cells of its own. Variable tokens (`{{name}}`) are counted as written. **Notes words** are the words of `notes`.\n- **Speaking time** is `notesWords / wordsPerMinute`. It is an estimate of reading the notes aloud at a steady pace, labelled as one in the report. The deck's own `duration` is reported next to it, not compared to it.\n- **Images.** `content` counts image payloads (slide root, blocks, regions) and a slide image (`design.slideImage`) that names a source. Alt text follows the `asset:` references to the registry. An alt text that is empty (`alt: \"\"`) is *decorative*, the explicit choice `opf validate` accepts; no alt, or only whitespace, is *missing*. Logos, watermarks and image backgrounds are decorative or brand assets by nature and are counted separately, not as content images.\n- **Assets.** A source starting `asset:` is a registry reference, `data:` is embedded, `http(s):` (and `pkg:`) is remote, anything else is a file (a relative path resolved against the OPF file, or a local path). `embeddedBytes` is the decoded size of `data:` URIs (exact for base64). A chart that takes its data from an external source counts that source as a use; a `source` on inline data or a dataset is provenance, never read, and is not a use.\n- **Fonts.** Families come from the font schemes in effect (the deck's, and a slide's own scheme or theme), inline scheme records, and `fontFamily` on runs, resolved the way every engine resolves them. A scheme that names no `code` role does not add a code family.\n\n## What stats leaves out\n\nEverything in the schema that is presentation detail rather than a countable or present fact is left out; the deck itself has it: text run formatting (bold, colour, size), list numbering styles, chart styling (axis titles, legend, data labels, series mapping), table cell styles and spans, `composition` hints, design preferences such as alignment, `contentBox`, `contentDirection`, `chartPrimary`, `imageFill`, `listBullet` and the placement of a slide image, solid and gradient backgrounds, the individual `Language`, `Tone` and `Audience` record fields, and the contents of `extensions`. Findings (alt text that is missing, a missing title, text that overflows) belong to `opf validate`, which reports them with severities; `stats` only counts the underlying things.\n"
295
+ "markdown": "# Stats\n\n`stats(presentation, options?)` and `opf stats <file|->` report neutral facts about a deck: what is in it and how much. Counts and lists only. There are no severities, no judgments and no thresholds, so nothing here says a deck is too long, too dense or missing something; `opf validate` says what is wrong, and a reviewer decides what is good.\n\n```js\nimport { stats } from '@openpresentation/opf';\n\nconst facts = stats(deck);\nfacts.slides.total; // 12\nfacts.words.content; // 842 words on the slides\nfacts.notes.withoutNotes; // [{ index: 3, id: 'pricing' }, ...]\nfacts.images.content.missingAlt; // 2\nfacts.speakingTime; // { basis: 'estimate', wordsPerMinute: 130, notesWords: 410, minutes: 3.2, declaredMinutes: 20 }\nstats(deck, { perSlide: true }).perSlide; // one row per slide\n```\n\n```sh\nopf stats deck.opf.json # JSON\nopf stats deck.opf.json --format text # one readable block per topic\nopf stats - --per-slide < deck.opf.json # adds a row per slide\n```\n\n## What it does and does not do\n\n- **Structural only.** It walks the JSON. It does not compose slides, build layouts, measure text or need fonts, and it does not validate: a deck that fails `opf validate` still has facts, and an unknown id is reported as written. It reads what is there and never throws on a malformed deck (it throws only for an argument that is not an object).\n- **Cheap.** Measured on the 126 bundled example decks, warm, with the per-slide breakdown: 18 ms for all of them (0.14 ms mean), against 46 ms for `validate(deck, { only: ['format'] })`, 290 ms for `only: ['format', 'references']` and 1.1 s for a full `validate` over the same decks (one machine; the ratios hold). Template variables are read with `listVariables`; nothing else loads a catalog except the theme and font-scheme records that name a size and families.\n- **Deterministic.** The same deck gives the same output with the same key order: objects are built in a fixed order, and every count map and id list is sorted (slide lists keep deck order). Nothing reads a clock, a locale or the runtime's Unicode word-break data.\n- **Pure.** The input is not changed.\n\n`stats(presentation, { perSlide?, values?, wordsPerMinute? })`: `perSlide` adds `perSlide`, `values` is a variables map to count as filled (as for `resolveVariables`), `wordsPerMinute` sets the speaking pace of the estimate (default 130). The root also exports the result types (`PresentationStats`, `SlideStats`, ...).\n\n## The report\n\n| Section | Facts |\n| --- | --- |\n| `deck` | `name`, `description` (is there one), `filename`, `author`, `language`, `slideSize` (`preset`, `widthInches`, `heightInches`, `aspectRatio`, and `source`: the deck's `design.dimensions`, its theme, or the default), `template`, `tags`, `takeaways` (how many), `declaredMinutes`, `design` (`theme`, `colorScheme`, `fontScheme`), `audience`, `purpose`, `tone`, `narrative` (with its `beats` count). A reference is `{ id, inline }`: the string as written (a catalog id, URL, `pkg:` reference or free text) or an inline record's `id`, with `inline: true` for the object form. |\n| `people` | `organizations` (`id`, `name`, `role`, `hasLogo`) and `speakers` (`id`, `name`, `title`, `organizationId`, `hasPhoto`). |\n| `slides` | `total`; `hidden` (`[{ index, id }]`, where `index` is the zero-based position, the same as `/slides/<index>`); `withTitle`, `withoutTitle`; `withBeat`; `sections` (consecutive slides with the same `section` label: `name`, `slides`, `firstSlide`) and `unsectioned`; `layouts` (slides per layout id) and `withoutLayout` (layout left to inference); `withOwnDesign` (slides whose `design` overrides the deck). |\n| `payloads` | Content payloads by kind across slide roots, blocks and promoted regions: `text`, `items`, `bullets`, `quote`, `metric`, `code`, `timeline`, `chart`, `table`, `image`, `video`; plus `blocks` (block entries at any depth), `regions` (promoted regions such as `left` or `top:center`), `groups` (blocks and regions that hold nested `blocks`) and `maxDepth` (deepest block nesting). |\n| `words` | `content`, `notes`, `total`, and `perSlide` (`min`, `max`, `mean` of content words). |\n| `notes` | `withNotes` and `withoutNotes` (`[{ index, id }]`). A note of only whitespace is no note. |\n| `speakingTime` | `basis: 'estimate'`, `wordsPerMinute`, `notesWords`, `minutes` (notes words divided by the pace, to a tenth), `declaredMinutes` (the deck's own `duration`). |\n| `images` | `content` (`total`, `withAlt`, `decorative`, `missingAlt`), `logos`, `watermarks` (`deck`, `slides`), `backgrounds` (`deck`, `slides`), `headerFooter`, `speakerPhotos`, `videos`. |\n| `charts` | `total`, `byType`, and `data`: how many take `inline` data, a `dataset` reference or an external `source`. |\n| `tables` | `total`, `datasetBacked`, total `rows` and `columns`, and `items` (`slide`, `path`, `rows`, `columns`, `dataset`). |\n| `datasets` | `count`, `withSource`, and `items` (`id`, `title`, `columns`, `rows`, `source`: the declared `source.src` or null, `referencedBy`: charts and tables that use it). |\n| `citations` | `references`, `cited` (reference ids some run cites), `citations` (cited runs), `footnotes`, `captions`, `links`. |\n| `variables` | `declared`, `byKind`, `required`, `optional`, `filled`, `unfilled`, `unfilledRequired`, `unused` (declared, never referenced). |\n| `assets` | `registry` (`entries`, `embedded`, `files`, `remote`), `uses` (every asset-bearing field: `total`, `references`, `embedded`, `files`, `remote`) and `embeddedBytes`. |\n| `headerFooter` | `header` and `footer` at deck level (`configured`, `suppressed`, `zones`, `fields`) and `slides` (`headerOverrides`, `headerSuppressed`, `footerOverrides`, `footerSuppressed`). |\n| `fonts` | `families` (every family named), `schemeIds` (font schemes in effect), `unresolvedSchemeIds`, `runOverrides` (families set on text runs). |\n| `colors` | `variables`: each colour variable with its `value` and number of `uses`. |\n| `extensions`, `catalogs` | The keys under `extensions` anywhere; each catalog group the deck embeds records in, with its `source` and its record count per kind. |\n| `perSlide` | With `perSlide: true`: per slide `index`, `id`, `title`, `layout`, `section`, `hidden`, `payloads` (kinds present), `words` (`content`, `notes`), `hasNotes`, `speakingSeconds`, `images`, `charts`, `tables`, `citations`. |\n\n## Definitions\n\n- **Words.** Letters and digits in a run, with an internal apostrophe, hyphen, comma or period joining parts (`don't`, `well-known`, `3.5`, `$4.2M` is one word). Thai, Lao, Myanmar, Khmer, kana and Han text, which has no spaces between words, counts one unit per character: a fixed rule, so a count never depends on the runtime's Unicode data. **Content words** are the visible text on slides: `title`, `subtitle`, `tag`, text, items (and their descriptions), bullets, quote text, attribution and source, metric fields, timeline fields, table headers and cells (a number is one word), and captions. Code, chart data, header and footer text and reference texts are not counted. A dataset-backed table has no cells of its own. Variable tokens (`{{name}}`) are counted as written. **Notes words** are the words of `notes`.\n- **Speaking time** is `notesWords / wordsPerMinute`. It is an estimate of reading the notes aloud at a steady pace, labelled as one in the report. The deck's own `duration` is reported next to it, not compared to it.\n- **Images.** `content` counts image payloads (slide root, blocks, regions, placed or not). Alt text follows the `asset:` references to the registry. An alt text that is empty (`alt: \"\"`) is *decorative*, the explicit choice `opf validate` accepts; no alt, or only whitespace, is *missing*. Logos, watermarks and image backgrounds are decorative or brand assets by nature and are counted separately, not as content images.\n- **Assets.** A source starting `asset:` is a registry reference, `data:` is embedded, `http(s):` (and `pkg:`) is remote, anything else is a file (a relative path resolved against the OPF file, or a local path). `embeddedBytes` is the decoded size of `data:` URIs (exact for base64). A chart that takes its data from an external source counts that source as a use; a `source` on inline data or a dataset is provenance, never read, and is not a use.\n- **Fonts.** Families come from the font schemes in effect (the deck's, and a slide's own scheme or theme), inline scheme records, and `fontFamily` on runs, resolved the way every engine resolves them. A scheme that names no `code` role does not add a code family.\n\n## What stats leaves out\n\nEverything in the schema that is presentation detail rather than a countable or present fact is left out; the deck itself has it: text run formatting (bold, colour, size), list numbering styles, chart styling (axis titles, legend, data labels, series mapping), table cell styles and spans, `composition` hints, design preferences such as alignment, `contentBox`, `contentDirection`, `chartPrimary`, `imageFit`, `listBullet`, image treatments and placement, solid and gradient backgrounds, the individual `Language`, `Tone` and `Audience` record fields, and the contents of `extensions`. Findings (alt text that is missing, a missing title, text that overflows) belong to `opf validate`, which reports them with severities; `stats` only counts the underlying things.\n"
296
296
  },
297
297
  {
298
298
  "slug": "status-2026-09-15",
@@ -310,25 +310,25 @@ var docsData = Object.freeze([
310
310
  "slug": "table-text-colors",
311
311
  "file": "docs/table-text-colors.md",
312
312
  "title": "Inherited table text colors",
313
- "markdown": "# Inherited table text colors\n\nThe published core 0.11.0 and later, renderer 0.9.0 and later, and PPTX 0.9.1 and later (current core 0.13.0, renderer 0.13.1, PPTX 0.13.2) use one core rule for inherited table text colors in SVG and editable PowerPoint cells. After resolving the cell fill, keep the inherited text color when its unrounded contrast is at least 4.5:1. Otherwise choose the higher-contrast black or white. This covers pale headers and dark body-cell fills without changing the source document.\n\nAn explicit cell `style.color` or rich-text run `color` remains authoritative, including a deliberately low-contrast color. Translucent fills and unresolved colors keep the inherited preference: their actual backdrop must be known before assessing contrast. This rule does not alter fills, borders, fonts, layout or metadata.\n\nCore exports `colorContrast(foreground, background)` and `textColorForFill(fill, preferred)` from its `/composition` entrypoint. They accept opaque hexadecimal `#RGB`, `#RRGGBB` and `#RRGGBBFF` colors. `colorContrast` returns `undefined` for unsupported or translucent colors. Callers must apply explicit text-color overrides before invoking the fallback.\n\nThe ratio uses [W3C's sRGB relative luminance definition](https://www.w3.org/WAI/WCAG22/Understanding/contrast-minimum.html). Passing this narrow color check is not a WCAG certification, a readability guarantee, a browser/native raster equivalence result or an arbitrary PowerPoint round-trip claim. Source tests check actual SVG attributes and native OOXML text colors, including inherited and explicit rich-text colors. A [recorded six-slide real PowerPoint test](handoff-2026-09-08.md) passed on Node 20.20.2 and 24.20.0 at its source checkpoint: all 48 original/reopened cell observations and 624 character-color observations match per runtime. Those historical native rasters remain distinct from current-package and browser acceptance.\n"
313
+ "markdown": "# Inherited table text colors\n\nThe published core 0.11.0 and later, renderer 0.9.0 and later, and PPTX 0.9.1 and later (current core 0.14.0, renderer 0.14.0, PPTX 0.14.0) use one core rule for inherited table text colors in SVG and editable PowerPoint cells. After resolving the cell fill, keep the inherited text color when its unrounded contrast is at least 4.5:1. Otherwise choose the higher-contrast black or white. This covers pale headers and dark body-cell fills without changing the source document.\n\nAn explicit cell `style.color` or rich-text run `color` remains authoritative, including a deliberately low-contrast color. Translucent fills and unresolved colors keep the inherited preference: their actual backdrop must be known before assessing contrast. This rule does not alter fills, borders, fonts, layout or metadata.\n\nCore exports `colorContrast(foreground, background)` and `textColorForFill(fill, preferred)` from its `/composition` entrypoint. They accept opaque hexadecimal `#RGB`, `#RRGGBB` and `#RRGGBBFF` colors. `colorContrast` returns `undefined` for unsupported or translucent colors. Callers must apply explicit text-color overrides before invoking the fallback.\n\nThe ratio uses [W3C's sRGB relative luminance definition](https://www.w3.org/WAI/WCAG22/Understanding/contrast-minimum.html). Passing this narrow color check is not a WCAG certification, a readability guarantee, a browser/native raster equivalence result or an arbitrary PowerPoint round-trip claim. Source tests check actual SVG attributes and native OOXML text colors, including inherited and explicit rich-text colors. A [recorded six-slide real PowerPoint test](handoff-2026-09-08.md) passed on Node 20.20.2 and 24.20.0 at its source checkpoint: all 48 original/reopened cell observations and 624 character-color observations match per runtime. Those historical native rasters remain distinct from current-package and browser acceptance.\n"
314
314
  },
315
315
  {
316
316
  "slug": "templates-and-variables",
317
317
  "file": "docs/templates-and-variables.md",
318
318
  "title": "Templates and variables",
319
- "markdown": '# Templates and variables\n\n> A template is just an incomplete OPF file. (Owner intent, 2026-10-01.)\n\nThis is the design and the reference for OPF variables and templates (release readiness item RR-32). A variable is a typed, named value a deck declares once and uses in many places. A template is an ordinary OPF document that declares variables, references them from its content, and leaves some of them unfilled. Filling a template (a function call, `opf fill`, or the editor\'s Fill template panel) returns a normal deck. A template plus its values previews and exports exactly like the hand-written deck it stands for, because every engine resolves variables with one core function before it composes anything.\n\nDecisions in this document are vetoable. They are collected in [Decisions](#decisions); each says what was chosen and what the alternative would cost.\n\n## At a glance\n\n```json\n{\n "template": true,\n "name": "Quarterly review for {{client}}",\n "variables": {\n "client": { "type": "text", "label": "Client", "example": "Acme Corp" },\n "revenue": { "type": "number", "format": "$#,##0", "example": 1250000 },\n "kickoff": { "type": "date", "example": "2026-10-01" },\n "wins": { "type": "list", "example": ["Faster onboarding", "Lower churn"] },\n "logo": { "type": "image", "required": false },\n "risk": "#B42318"\n },\n "slides": [\n { "id": "cover", "title": "Quarterly review: {{client}}", "subtitle": "Kickoff {{kickoff}}", "image": "var:logo" },\n { "id": "wins", "title": "Wins", "bullets": ["Revenue {{revenue}}", "var:wins"] },\n { "id": "chart", "title": "Revenue", "chart": { "type": "column", "data": { "columns": ["Quarter", "Revenue"], "rows": [["This quarter", "var:revenue"]] } } }\n ]\n}\n```\n\n```sh\nopf fill quarterly.opf.json --data clients.csv --out-dir decks # one deck per CSV row\nopf fill quarterly.opf.json --data one.json --output globex.opf.json\n```\n\n```js\nimport { resolveVariables, validate } from \'@openpresentation/opf\';\n\nvalidate(template); // valid: { template: true, unfilledVariables: [...] }\nconst { presentation, diagnostics } = resolveVariables(template, { client: \'Globex\', revenue: 1250000, kickoff: \'2026-10-01\', wins: [\'Shipped v2\'] });\n// presentation is a concrete deck: validate(presentation).valid === true\n```\n\nA fuller template (rich text, an optional image, a link, a percentage) with matching values is in [fixtures/template-quarterly-review.opf.json](fixtures/template-quarterly-review.opf.json) and [fixtures/template-quarterly-review.values.json](fixtures/template-quarterly-review.values.json); a test fills it, validates the result and previews it from its examples.\n\n## Variable kinds\n\nA variable is declared in the root `variables` map, keyed by a kebab-case id (`^[a-z][a-z0-9-]*$`). The value is a hex string (shorthand for a color variable, unchanged from before) or an object whose `type` selects the kind.\n\n| Kind | `value` / `example` | Inline `{{id}}` | Whole field `var:id` |\n| --- | --- | --- | --- |\n| `color` | hex color | the hex text | the existing color path (see below) |\n| `text` | string, or `TextRun[]` for rich text | the text (rich text is flattened) | the string or the runs |\n| `number` | finite number | formatted with `format` | the number itself |\n| `date` | ISO `YYYY-MM-DD` | formatted with `format` (default `MMMM d, yyyy`) | the ISO string |\n| `image` | any [Asset](schema-reference.md#asset): `asset:<id>`, HTTPS URL, data URI, path, or `{src, alt, ...}` | the source string | the string or the Asset object |\n| `url` | `http`, `https`, `mailto` or `tel` link | the link | the link |\n| `list` | array of strings | entries joined with `, ` (or `{{id\\|sep}}`) | the array; as an array element it splices |\n\nEvery object variable may also carry `label` (short form label), `description`, `required` (default `true`), and `example`.\n\n- `value` is the current value. A variable with a `value` is filled; supplying a value to `resolveVariables` or `opf fill` overrides it.\n- `example` only illustrates the slot. Fill forms show it as a placeholder, and previews of a template use it (see [Previews](#previews-and-export)). It never reaches a filled deck.\n- `required: false` makes a variable optional. An unfilled optional variable resolves to empty text inline, and a field that references it as `var:id` is omitted.\n- `format` (number and date only) is the display pattern used by `{{id}}`.\n\nColor variables are unchanged: `"risk": "#B42318"` or `{ "type": "color", "value": "#B42318" }`, referenced as `var:risk` in color fields, resolved at render time by `resolveColorRef`. The only addition is that a color variable can now be filled or overridden like the others.\n\n### Number formats\n\nA pattern is an optional literal prefix, a numeric part of `#`, `0`, `,` and `.`, and an optional literal suffix. `0` pads digits, `#` is optional, a comma groups thousands, the digits after the point fix the decimals (`0` required, `#` optional), and a `%` in the prefix or suffix scales by 100. Rounding is half away from zero in decimal (`1.005` with `0.00` is `1.01`). Separators are English. With no pattern a number prints in its shortest decimal form.\n\n| Pattern | Value | Result |\n| --- | --- | --- |\n| `#,##0` | `1234567` | `1,234,567` |\n| `$#,##0.00` | `-1234.5` | `-$1,234.50` |\n| `0.#%` | `0.256` | `25.6%` |\n| `#,##0.0M` | `12.34` | `12.3M` |\n\n### Date formats\n\nDates are calendar dates, never instants: no clock, no time zone. The pattern uses the same LDML-style tokens as header and footer `dateFormat` (`yyyy`, `yy`, `MMMM`, `MMM`, `MM`, `M`, `dd`, `d`, `EEEE`, `EEE`, quoted literals) with fixed English names. `2026-10-01` with `dd MMM yyyy` is `01 Oct 2026`.\n\n## Using a variable\n\nTwo forms, one rule each.\n\n**Inline token `{{id}}`.** Inside any string of the document. The string keeps its other characters, so `"Revenue {{revenue}} for {{client}}"` is one string with two substitutions. A token may name a one-off format after a pipe: `{{kickoff|dd MMM yyyy}}`, `{{revenue|0.0}}`, `{{wins|; }}` (the list separator). Whitespace inside the braces is ignored (`{{ client }}`). The result is always text.\n\n**Whole-field reference `var:id`.** A string that is exactly `var:<id>` is replaced by the variable\'s typed value: a number in a chart cell, an Asset object in `image`, the runs of a rich text variable in a `text` field, the entries of a list variable in `bullets`. Inside an array, a list variable splices its entries in place (`["first", "var:wins", "last"]`). Color variables keep their existing meaning in color fields.\n\nWhich fields can use variables is decided by the schema of the *resolved* deck, not by a field list: any string value of the document, wherever it sits (titles, notes, run text and links, table cells, chart data, metric values, image sources, asset registry entries, even code), can carry a token or a reference. If the substituted value does not fit the field (a list in a title), validation of the resolved deck reports it with the field\'s path.\n\n**Escaping.** Write `\\{{` for a literal `{{` (in JSON, `"\\\\{{"`). Only that sequence is special. A token whose id is not declared is left as written, and the validator warns when the deck uses variables (`\'{{ghost}}\' names no declared variable`).\n\n**Rich text.** A `text` variable whose value is `TextRun[]` keeps its runs through a whole-field reference in any field that accepts string or `TextRun[]`: `"text": "var:greeting"`, and the slide `"title"`, `"subtitle"`, `"tag"` and a quote\'s `"text"` (FA-10). A `{{id}}` token inside a heading run resolves like one in a body run. Inside a larger string it is flattened to plain text with an informational `variable-rich-flattened` diagnostic. To style an inline value, put the token in a run: `{ "text": "{{client}}", "bold": true }`.\n\n**Untouched content.** `extensions`, inline `catalogs`, `$schema`, and the declarations themselves are never searched.\n\n**Compatibility.** A deck that declares no content variable and is not a template is resolved by identity: nothing is searched, `{{` and `\\{{` in its text keep their meaning (a Handlebars snippet in a code block is safe), and its color variables behave as before. Escapes are only processed in a deck that uses content variables.\n\n## Built-in variables\n\nBuilt-ins are read-only variables that come from the deck\'s own metadata, so a cover can say `{{speaker.name}}, {{speaker.title}} \xB7 {{organization.name}}` without declaring anything. Their names are dotted. A user-defined id matches `^[a-z][a-z0-9-]*` and never contains a dot, so a built-in can never collide with a user variable. (The one built-in without a dot, `speakers`, is a reserved id: declaring a variable named `speakers` is a validation error.)\n\n| Built-in | Kind | Source |\n| --- | --- | --- |\n| `deck.name`, `deck.description` | text | root `name`, `description` |\n| `deck.author` | text | root `author` (an array is joined with `, `) |\n| `speaker.name`, `.title`, `.email`, `.phone`, `.bio` | text | the first speaker |\n| `speaker.photo` | image | the first speaker |\n| `speakers` | list | every speaker\'s name, in order |\n| `speaker.<id>.<field>` | as above | the speaker with that id |\n| `organization.name`, `.legalName`, `.tagline`, `.domain`, `.email`, `.phone` | text | the primary organization |\n| `organization.logo` | image | the primary organization |\n| `organization.<id>.<field>` | as above | the organization with that id |\n\nThe primary organization is the one with `role: \'primary\'`, else the first (the rule the deck logo and the `organization` furniture field use). The speaker and organization fields may be an object or an array. A two-segment name (`speaker.name`) always means the first speaker or the primary organization; a three-segment name (`speaker.ada.name`) addresses an entry by its `id`.\n\nThey use the two forms above and work wherever the same kind of user variable works: `{{speaker.name}}` inside any string (`{{speakers|; }}` takes a separator, like any list), and `var:speaker.photo` or `var:organization.logo` as a whole field. `var:speakers` splices every name into an array.\n\n```json\n{\n "speaker": { "id": "ada", "name": "Ada Lovelace", "title": "CTO", "photo": "asset:ada" },\n "assets": { "ada": { "src": "./photos/ada.jpg", "mediaType": "image/jpeg" } },\n "organization": { "id": "acme", "name": "Acme Corp", "tagline": "Build the future" },\n "design": { "footer": { "left": { "text": "{{organization.tagline}}" } } },\n "slides": [{ "id": "cover", "title": "Quarterly review", "subtitle": "{{speaker.name}}, {{speaker.title}} \xB7 {{organization.name}}", "image": "var:speaker.photo" }]\n}\n```\n\n- **Unknown path.** `{{speaker.nickname}}`, `var:organization.nobody.name` or `{{deck.owner}}` is a validation error (`variable-unknown-builtin`) and stays as written in a resolved deck. A `speaker.<id>` or `organization.<id>` path whose id does not exist is the same error.\n- **Known path, no source value.** The built-in resolves to an empty string (a whole-field reference is omitted) and validation warns (`variable-builtin-missing`), as for an unfilled optional variable.\n- **Templates.** A template preview (`examples: true`) uses the document\'s real metadata for built-ins. When the document has none, the token or reference stays visible, the way an unfilled variable with no example does. Filling the template for real resolves a missing built-in to nothing.\n- **Nested tokens.** A built-in text that itself carries a token (`"name": "Review for {{client}}"`) is resolved once; a built-in that refers back to itself stays as written.\n- **Not overridable.** `values` cannot set a built-in; change the document field instead.\n- **Resolved before composition,** like user variables, so every engine draws the same text. `listBuiltinVariables(presentation)` returns each built-in with its kind, label, current value, whether the document has a source value, and where it is used (pickers and agents; the editor\'s Fill template panel lists them read-only).\n- The speaker and organization fields that are *not* built-ins are never drawn automatically: a speaker appears only through these variables and the `speaker` header/footer field, and `Organization.tagline`, `legalName`, `domain`, `email` and `phone` appear only through their built-ins. Per-slide values (slide number, total, section, date) stay header/footer fields: they need composition-time resolution and native PPTX fields.\n\nThe `speaker: true` header/footer field draws the first speaker\'s name and title ("Ada Lovelace, CTO") as generated text in that zone, after `organization` in the stack order. See [dynamic composition](dynamic-composition.md).\n\n## Templates\n\nA template is an OPF document that is allowed to be incomplete.\n\n- The root `template: true` marks it. Without the marker a deck is a normal deck.\n- Validation of a template reports instead of fails. `validate(doc)` returns `valid: true` with `template: true` and `unfilledVariables: [\'client\', ...]`, and one `opf/variable-unfilled` warning per unfilled variable. A normal deck with an unfilled required variable gets the same finding as an error (category `format`, so `validate(doc, { only: [\'format\'] })`, `assertValid` and every write command reject it).\n- Validation of a normal deck with an unfilled required variable is an error (`required variable \'client\' has no value`) at the declaration\'s path, so a half-filled deck can never be exported by accident.\n- Both are checked as the deck they would become: every variable is replaced by its value, its `example`, or a type sample (`Sample text`, `0`, `2000-01-01`, a placeholder image URL, one list entry), and the declarations stay. Errors therefore carry the source\'s own paths and a template is held to the whole schema: a `number` variable in a text field, a `date` value that is not a date, or a chart cell that resolves to a list are all reported.\n- `validate(doc, { template: true | false })` overrides the marker, and `validate(doc, { values })` fills before checking.\n- Unused variables warn (`declared but never used`), as do tokens naming undeclared ids.\n- A variable with a `value` is a default: filling overrides it, leaving it blank in a data row keeps it.\n\nA template is a normal OPF file for everything else: the editor opens it, `opf validate` and `opf edit` accept it, pagination and bundling work on it, and renderers preview it.\n\n### Placeholder content\n\n"Empty slots the author must fill" are required variables. There is no separate placeholder construct. A slide, block or field that should not exist unless supplied uses an optional variable (`required: false`): its `var:id` field is omitted when the variable is unfilled.\n\n## Resolution\n\n```ts\nresolveVariables(presentation, values?, options?) => {\n presentation, // the concrete deck\n diagnostics, // { code, severity, path, id, message }[]\n unfilled, // required variable ids with no value\n examplesUsed, // ids filled from their `example`\n complete, // unfilled.length === 0\n}\n```\n\nPure and deterministic: no clock, locale, time zone, network, file access or randomness, and no model call. The function never invents content. A variable\'s value comes from `values`, then from the declaration\'s `value`, and only with `options.examples` from its `example`. The input is never mutated; unchanged parts of it are shared with the result, so treat the result as immutable.\n\n| Option | Effect |\n| --- | --- |\n| `examples` | Use each unfilled variable\'s `example` (template previews). |\n| `partial` | Allow unfilled required variables (informational). Their tokens and references stay as written and their declarations stay in the output, so a second pass can finish the deck. |\n| `template` | Override the root marker: a template treats unfilled variables as informational. |\n| `strict` | Throw `OPFVariableError` when any diagnostic has severity `error`. |\n\nThe result of a complete pass:\n\n- Content variables are substituted and their declarations removed, so resolving the result again changes nothing (no double expansion, no re-interpreted `\\{{`).\n- Color variables keep their declarations and their `var:` references (the renderers and the exporter resolve them as before), with values from `values` or `example` written in.\n- The root `template` marker is removed.\n- The result validates as an ordinary deck.\n\nValues are coerced per kind, so data files work as they are: number accepts a finite number or a strict decimal string (`"1250000"`, not `"1,250,000"`); date accepts `YYYY-MM-DD` or an ISO date-time (the date part is kept, no zone conversion); list accepts an array, or a string split on newlines; text accepts a string, a number, a boolean or runs; image accepts a string or `{src}`; url accepts http, https, mailto and tel; color accepts hex. `null`, `undefined` and, for every kind but text, a blank string mean "not provided". A rejected value is an `error` diagnostic (`variable-invalid-value`) and falls back to the declaration. A value for an undeclared variable is a `variable-unknown-value` warning.\n\nDiagnostic codes: `variable-unfilled` (error in a deck, info in a template or partial fill), `variable-invalid-value`, `variable-format` (an unusable number or date pattern), `variable-unknown` (undeclared token), `variable-unknown-value`, `variable-unused`, `variable-example-used`, `variable-rich-flattened`.\n\n`listVariables(presentation, values?)` returns each declaration with `filled` and every place it is used (`uses: [{ path, form: \'token\' | \'reference\' }]`). Fill forms, agents and the editor read it.\n\n## Previews and export\n\nThe renderer, the PPTX exporter and the editor call `resolveVariables` at their entry points, so preview and export agree on text, numbers, dates, images and lists:\n\n- `renderSvg`, `renderSvgDeck`, `resolvePresentation` and `toPptx` accept `variables` (the values) and resolve the deck first when it uses content variables or is a template. A deck without them is untouched, byte for byte.\n- A template is previewed and exported with each unfilled variable\'s `example`, and reports `variable-example-used` through `onDiagnostic` (PPTX) so the sample content is never silent. A variable with no example keeps its `{{id}}` text visible; nothing is made up.\n- A normal deck with an unfilled required variable is refused: `OPFRenderError` or `OPFPptxError` with code `unfilled-variables`.\n- `variables: false` draws the document as authored, tokens and `var:` references visible (the editor canvas\'s view of a template).\n- A core older than this feature (no `resolveVariables`) leaves variable-free decks working and rejects a deck that uses content variables at validation, as any unknown schema construct is rejected.\n\nThe PPTX file contains the resolved text. The template form is not stored in the package, so importing the PPTX returns the filled deck, not the template (see [Follow-ups](#follow-ups)).\n\n## Decks from data: `opf fill`\n\n```\nopf fill <template|-> [--data <values.json|data.csv|data.tsv|->] [--format csv|tsv|json]\n [--delimiter <c>] [--no-header]\n [--output <file|-> | --out-dir <dir> [--name <pattern>] | --combine --output <file|->]\n [--partial] [--examples] [--force] [--fail-on <level>]\n```\n\n- **Records.** A JSON object is one record. A JSON array of objects, a CSV or TSV file, `{columns, rows}` and a row matrix give one record per row; CSV and TSV columns are matched to variable ids by header name. JSON keeps rich values (text runs, lists, Asset objects); CSV and TSV cells are strings that coerce per kind. A blank cell keeps the declared value.\n- **Outputs.** One record gives one deck (`--output`, default stdout). Several records need `--out-dir` (one `<name>.opf.json` per record; `--name` is a pattern with `{n}`, the zero-padded index, and `{column}`, a slug of that column\'s value; default `deck-{n}`) or `--combine` (one deck whose slides are the filled slides of every record in order, with repeated slide ids suffixed by the record index; deck-level fields come from the first record).\n- **Failure.** An unfilled required variable, a value of the wrong kind or an unusable format fails with exit code 1 and the offending record and variable, before any file is written. `--partial` allows unfilled variables and keeps their declarations; `--examples` fills them from their example.\n- **Overlap with `import-data`.** `import-data` turns tabular data into a table or chart *content* payload and shares the same CSV/TSV/JSON parser. `fill` maps rows onto *variables* of a deck the author designed. They compose: a chart whose rows come from a data file stays `import-data`; a chart cell that varies per client is a `var:` reference.\n- Relative image paths in data are written as given, so they resolve against the OPF file\'s final location. Prefer HTTPS URLs, data URIs, `asset:` ids or absolute paths in data files, or write decks next to their images.\n\n## Editor\n\nThe editor package exposes the fill model (`@openpresentation/opf-editor/templates`) and a **Fill template** panel (`/template-panel`, mounted by the playground): variables listed with typed inputs (text, multi-line text, number, date, color, URL, list, and an image source with file upload or `asset:` pick), required and filled state, where each is used, a live preview that re-resolves on every change, and a count of unfilled variables. Inserting a variable token into a text field (declaring a new variable in the same edit when asked) is an edit through the session, so it validates and undoes like any other. Filling replaces the document with the concrete deck as one undoable edit. The canvas draws a template as authored (renderer option `variables: false`), so tokens stay visible and an inline edit never overwrites one; the panel\'s preview draws the resolved deck. See the editor README.\n\n## Decisions\n\nEach is vetoable; the alternative says what changing it would cost.\n\n1. **Token syntax `{{id}}` with ids matching the existing variable id pattern.** Familiar from Mustache, Handlebars and Jinja, readable in JSON, and not a valid identifier in prose. Alternatives: `${id}` (collides with template literals in code), `[[id]]`, dotted paths such as `{{client.name}}` for user variables (no nesting exists in the variable model; nested records are a follow-up). Dotted names are reserved for the [built-in variables](#built-in-variables), which is why a user id can never contain a dot.\n2. **Escape `\\{{`.** One rule with one sequence. `{{{{` doubling was rejected because it is ambiguous next to a real token.\n3. **Whole-field `var:id` carries typed values; inline `{{id}}` always makes text.** One reason each: a chart cell needs a number, a title needs a string. `var:` already existed for colors.\n4. **No `{ "var": "id" }` TextRun form.** Tokens inside a run\'s `text` give the same styling (`{ "text": "{{client}}", "bold": true }`) without a schema change to `TextRun`, and rich values travel through `var:id`. A second form would double the surface every consumer must handle.\n5. **Kinds: color, text, number, date, image, url, list.** `boolean` and nested record or table kinds are not included: no field consumes a boolean, and per-row structure belongs to `import-data` and the follow-up `repeat` construct.\n6. **A root `template: true` marker plus a `validate` option**, not an implied notion ("has unfilled variables"). An explicit marker keeps the strictness for normal decks (an unfilled variable cannot slip through) and survives in the file for editors and the gallery.\n7. **`value` is both the default and the current value; `example` is only illustration.** A third `default` field was rejected as redundant. `required` defaults to `true`.\n8. **Undeclared tokens stay literal.** Existing decks with `{{` in text, or code samples, are never altered.\n9. **Content declarations are consumed by resolution; color declarations stay.** Idempotence and backward compatibility respectively.\n10. **Renderers and the exporter call `resolveVariables`** (rather than require resolved input), so the editor can pass its in-progress values straight to the preview. Templates preview with examples; decks with unfilled required variables are refused at export.\n11. **No template provenance in PPTX in this release.** The package stores the resolved deck. Re-import returns the filled deck. Storing the template form needs a provenance part and a re-import path; recorded as a follow-up.\n12. **`opf fill`: a deck per record, plus `--combine`; no `repeat` construct yet.** A slide marked `repeat` (one instance per row inside one deck, with shared slides emitted once) is the natural next step but needs a schema addition and a per-record scope; `--combine` covers "one slide group per record" without it.\n13. **English number and date formats only.** Separators and names are fixed so output never depends on host locale; a `locale` field is a follow-up.\n14. **Optional unfilled variables vanish; required ones never do.** The resolver does not guess a replacement for a missing value.\n\n15. **Built-ins are dotted and read from the document, not declared.** A dot can never appear in a user id, so there is no collision rule to learn. The cost is one non-dotted name, `speakers`, which is reserved. Per-slide values are not built-ins because they need composition-time resolution and native PPTX fields.\n16. **A missing built-in source resolves to nothing with a warning (previews of a template keep the token).** An unknown path is an error because it is a typo, not missing data.\n\n## Limits\n\n- A variable\'s own `value` is taken literally: a token inside a value (`{{other}}`) is not expanded, so variables do not refer to each other.\n- Variables are not computed: no arithmetic, conditionals or loops. A deck that needs them generates its data upstream.\n- A variable cannot change structure beyond splicing a list into an array and omitting an optional field. Which slides exist is the author\'s choice (or `opf fill`\'s per-record decks).\n- Variables that point at relative image paths depend on where the filled file is saved.\n- Schema support is not fidelity: the renderer and exporter lay out the resolved deck, so a long substituted value can overflow like any long authored value. Check the filled deck with the usual composition diagnostics.\n\n## Follow-ups\n\n- A `repeat` slide construct and per-record scoping for one-deck-many-records fills.\n- Template provenance in PPTX export (re-import returns the template), and importing a PowerPoint deck with `{{id}}` tokens as a template.\n- Locale-aware number and date formats; conditional visibility of a block.\n- pptx.gallery: a template gallery and "use this template" flow; the gallery\'s examples gain optional variables.\n- Image variables backed by a host asset picker in hosts other than the playground.\n- A JSON Schema for a template\'s values file, generated from `listVariables`, for agents and form builders.\n'
319
+ "markdown": '# Templates and variables\n\n> A template is just an incomplete OPF file. (Owner intent, 2026-10-01.)\n\nThis is the design and the reference for OPF variables and templates (release readiness item RR-32). A variable is a typed, named value a deck declares once and uses in many places. A template is an ordinary OPF document that declares variables, references them from its content, and leaves some of them unfilled. Filling a template (a function call, `opf fill`, or the editor\'s Fill template panel) returns a normal deck. A template plus its values previews and exports exactly like the hand-written deck it stands for, because every engine resolves variables with one core function before it composes anything.\n\nDecisions in this document are vetoable. They are collected in [Decisions](#decisions); each says what was chosen and what the alternative would cost.\n\n## At a glance\n\n```json\n{\n "template": true,\n "name": "Quarterly review for {{client}}",\n "variables": {\n "client": { "type": "text", "label": "Client", "example": "Acme Corp" },\n "revenue": { "type": "number", "format": "$#,##0", "example": 1250000 },\n "kickoff": { "type": "date", "example": "2026-10-01" },\n "wins": { "type": "list", "example": ["Faster onboarding", "Lower churn"] },\n "logo": { "type": "image", "required": false },\n "risk": "#B42318"\n },\n "slides": [\n { "id": "cover", "title": "Quarterly review: {{client}}", "subtitle": "Kickoff {{kickoff}}", "image": "var:logo" },\n { "id": "wins", "title": "Wins", "bullets": ["Revenue {{revenue}}", "var:wins"] },\n { "id": "chart", "title": "Revenue", "chart": { "type": "column", "data": { "columns": ["Quarter", "Revenue"], "rows": [["This quarter", "var:revenue"]] } } }\n ]\n}\n```\n\n```sh\nopf fill quarterly.opf.json --data clients.csv --out-dir decks # one deck per CSV row\nopf fill quarterly.opf.json --data one.json --output globex.opf.json\n```\n\n```js\nimport { resolveVariables, validate } from \'@openpresentation/opf\';\n\nvalidate(template); // valid: { template: true, unfilledVariables: [...] }\nconst { presentation, diagnostics } = resolveVariables(template, { client: \'Globex\', revenue: 1250000, kickoff: \'2026-10-01\', wins: [\'Shipped v2\'] });\n// presentation is a concrete deck: validate(presentation).valid === true\n```\n\nA fuller template (rich text, an optional image, a link, a percentage) with matching values is in [fixtures/template-quarterly-review.opf.json](fixtures/template-quarterly-review.opf.json) and [fixtures/template-quarterly-review.values.json](fixtures/template-quarterly-review.values.json); a test fills it, validates the result and previews it from its examples.\n\n## Variable kinds\n\nA variable is declared in the root `variables` map, keyed by a kebab-case id (`^[a-z][a-z0-9-]*$`). The value is a hex string (shorthand for a color variable, unchanged from before) or an object whose `type` selects the kind.\n\n| Kind | `value` / `example` | Inline `{{id}}` | Whole field `var:id` |\n| --- | --- | --- | --- |\n| `color` | hex color | the hex text | the existing color path (see below) |\n| `text` | string, or `TextRun[]` for rich text | the text (rich text is flattened) | the string or the runs |\n| `number` | finite number | formatted with `format` | the number itself |\n| `date` | ISO `YYYY-MM-DD` | formatted with `format` (default `MMMM d, yyyy`) | the ISO string |\n| `image` | any [Asset](schema-reference.md#asset): `asset:<id>`, HTTPS URL, data URI, path, or `{src, alt, ...}` | the source string | the string or the Asset object |\n| `url` | `http`, `https`, `mailto` or `tel` link | the link | the link |\n| `list` | array of strings | entries joined with `, ` (or `{{id\\|sep}}`) | the array; as an array element it splices |\n\nEvery object variable may also carry `label` (short form label), `description`, `required` (default `true`), and `example`.\n\n- `value` is the current value. A variable with a `value` is filled; supplying a value to `resolveVariables` or `opf fill` overrides it.\n- `example` only illustrates the slot. Fill forms show it as a placeholder, and previews of a template use it (see [Previews](#previews-and-export)). It never reaches a filled deck.\n- `required: false` makes a variable optional. An unfilled optional variable resolves to empty text inline, and a field that references it as `var:id` is omitted.\n- `format` (number and date only) is the display pattern used by `{{id}}`.\n\nColor variables are unchanged: `"risk": "#B42318"` or `{ "type": "color", "value": "#B42318" }`, referenced as `var:risk` in color fields, resolved at render time by `resolveColorRef`. The only addition is that a color variable can now be filled or overridden like the others.\n\n### Number formats\n\nA pattern is an optional literal prefix, a numeric part of `#`, `0`, `,` and `.`, and an optional literal suffix. `0` pads digits, `#` is optional, a comma groups thousands, the digits after the point fix the decimals (`0` required, `#` optional), and a `%` in the prefix or suffix scales by 100. Rounding is half away from zero in decimal (`1.005` with `0.00` is `1.01`). Separators are English. With no pattern a number prints in its shortest decimal form.\n\n| Pattern | Value | Result |\n| --- | --- | --- |\n| `#,##0` | `1234567` | `1,234,567` |\n| `$#,##0.00` | `-1234.5` | `-$1,234.50` |\n| `0.#%` | `0.256` | `25.6%` |\n| `#,##0.0M` | `12.34` | `12.3M` |\n\n### Date formats\n\nDates are calendar dates, never instants: no clock, no time zone. The pattern uses the same LDML-style tokens as header and footer `dateFormat` (`yyyy`, `yy`, `MMMM`, `MMM`, `MM`, `M`, `dd`, `d`, `EEEE`, `EEE`, quoted literals) with fixed English names. `2026-10-01` with `dd MMM yyyy` is `01 Oct 2026`.\n\n## Using a variable\n\nTwo forms, one rule each.\n\n**Inline token `{{id}}`.** Inside any string of the document. The string keeps its other characters, so `"Revenue {{revenue}} for {{client}}"` is one string with two substitutions. A token may name a one-off format after a pipe: `{{kickoff|dd MMM yyyy}}`, `{{revenue|0.0}}`, `{{wins|; }}` (the list separator). Whitespace inside the braces is ignored (`{{ client }}`). The result is always text.\n\n**Whole-field reference `var:id`.** A string that is exactly `var:<id>` is replaced by the variable\'s typed value: a number in a chart cell, an Asset object in `image`, the runs of a rich text variable in a `text` field, the entries of a list variable in `bullets`. Inside an array, a list variable splices its entries in place (`["first", "var:wins", "last"]`). Color variables keep their existing meaning in color fields.\n\nWhich fields can use variables is decided by the schema of the *resolved* deck, not by a field list: any string value of the document, wherever it sits (titles, notes, run text and links, table cells, chart data, metric values, image sources, asset registry entries, even code), can carry a token or a reference. If the substituted value does not fit the field (a list in a title), validation of the resolved deck reports it with the field\'s path.\n\n**Escaping.** Write `\\{{` for a literal `{{` (in JSON, `"\\\\{{"`). Only that sequence is special. A token whose id is not declared is left as written, and the validator warns when the deck uses variables (`\'{{ghost}}\' names no declared variable`).\n\n**Rich text.** A `text` variable whose value is `TextRun[]` keeps its runs through a whole-field reference in any field that accepts string or `TextRun[]`: `"text": "var:greeting"`, and the slide `"title"`, `"subtitle"`, `"tag"` and a quote\'s `"text"` (FA-10). A `{{id}}` token inside a heading run resolves like one in a body run. Inside a larger string it is flattened to plain text with an informational `variable-rich-flattened` diagnostic. To style an inline value, put the token in a run: `{ "text": "{{client}}", "bold": true }`.\n\n**Untouched content.** `extensions`, inline `catalogs`, `$schema`, and the declarations themselves are never searched.\n\n**Compatibility.** A deck that declares no content variable and is not a template is resolved by identity: nothing is searched, `{{` and `\\{{` in its text keep their meaning (a Handlebars snippet in a code block is safe), and its color variables behave as before. Escapes are only processed in a deck that uses content variables.\n\n## Built-in variables\n\nBuilt-ins are read-only variables that come from the deck\'s own metadata, so a cover can say `{{speaker.name}}, {{speaker.title}} \xB7 {{organization.name}}` without declaring anything. Their names are dotted. A user-defined id matches `^[a-z][a-z0-9-]*` and never contains a dot, so a built-in can never collide with a user variable. (The one built-in without a dot, `speakers`, is a reserved id: declaring a variable named `speakers` is a validation error.)\n\n| Built-in | Kind | Source |\n| --- | --- | --- |\n| `deck.name`, `deck.description` | text | root `name`, `description` |\n| `deck.author` | text | root `author` (an array is joined with `, `) |\n| `speaker.name`, `.title`, `.email`, `.phone`, `.bio` | text | the first speaker |\n| `speaker.photo` | image | the first speaker |\n| `speakers` | list | every speaker\'s name, in order |\n| `speaker.<id>.<field>` | as above | the speaker with that id |\n| `organization.name`, `.legalName`, `.tagline`, `.domain`, `.email`, `.phone` | text | the primary organization |\n| `organization.logo` | image | the primary organization |\n| `organization.<id>.<field>` | as above | the organization with that id |\n\nThe primary organization is the one with `role: \'primary\'`, else the first (the rule the deck logo and the `organization` furniture field use). The speaker and organization fields may be an object or an array. A two-segment name (`speaker.name`) always means the first speaker or the primary organization; a three-segment name (`speaker.ada.name`) addresses an entry by its `id`.\n\nThey use the two forms above and work wherever the same kind of user variable works: `{{speaker.name}}` inside any string (`{{speakers|; }}` takes a separator, like any list), and `var:speaker.photo` or `var:organization.logo` as a whole field. `var:speakers` splices every name into an array.\n\n```json\n{\n "speaker": { "id": "ada", "name": "Ada Lovelace", "title": "CTO", "photo": "asset:ada" },\n "assets": { "ada": { "src": "./photos/ada.jpg", "mediaType": "image/jpeg" } },\n "organization": { "id": "acme", "name": "Acme Corp", "tagline": "Build the future" },\n "design": { "footer": { "left": { "text": "{{organization.tagline}}" } } },\n "slides": [{ "id": "cover", "title": "Quarterly review", "subtitle": "{{speaker.name}}, {{speaker.title}} \xB7 {{organization.name}}", "image": "var:speaker.photo" }]\n}\n```\n\n- **Unknown path.** `{{speaker.nickname}}`, `var:organization.nobody.name` or `{{deck.owner}}` is a validation error (`variable-unknown-builtin`) and stays as written in a resolved deck. A `speaker.<id>` or `organization.<id>` path whose id does not exist is the same error.\n- **Known path, no source value.** The built-in resolves to an empty string (a whole-field reference is omitted) and validation warns (`variable-builtin-missing`), as for an unfilled optional variable.\n- **Templates.** A template preview (`examples: true`) uses the document\'s real metadata for built-ins. When the document has none, the token or reference stays visible, the way an unfilled variable with no example does. Filling the template for real resolves a missing built-in to nothing.\n- **Nested tokens.** A built-in text that itself carries a token (`"name": "Review for {{client}}"`) is resolved once; a built-in that refers back to itself stays as written.\n- **Not overridable.** `values` cannot set a built-in; change the document field instead.\n- **Resolved before composition,** like user variables, so every engine draws the same text. `listBuiltinVariables(presentation)` returns each built-in with its kind, label, current value, whether the document has a source value, and where it is used (pickers and agents; the editor\'s Fill template panel lists them read-only).\n- The speaker and organization fields that are *not* built-ins are never drawn automatically: a speaker appears only through these variables and the `speaker` header/footer field, and `Organization.tagline`, `legalName`, `domain`, `email` and `phone` appear only through their built-ins. Per-slide values (slide number, total, section, date) stay header/footer fields: they need composition-time resolution and native PPTX fields.\n\nThe `speaker: true` header/footer field draws the first speaker\'s name and title ("Ada Lovelace, CTO") as generated text in that zone, after `organization` in the stack order. See [dynamic composition](dynamic-composition.md).\n\n## Templates\n\nA template is an OPF document that is allowed to be incomplete.\n\n- The root `template: true` marks it. Without the marker a deck is a normal deck.\n- Validation of a template reports instead of fails. `validate(doc)` returns `valid: true` with `template: true` and `unfilledVariables: [\'client\', ...]`, and one `opf/variable-unfilled` warning per unfilled variable. A normal deck with an unfilled required variable gets the same finding as an error (category `format`, so `validate(doc, { only: [\'format\'] })`, `assertValid` and every write command reject it).\n- Validation of a normal deck with an unfilled required variable is an error (`required variable \'client\' has no value`) at the declaration\'s path, so a half-filled deck can never be exported by accident.\n- Both are checked as the deck they would become: every variable is replaced by its value, its `example`, or a type sample (`Sample text`, `0`, `2000-01-01`, a placeholder image URL, one list entry), and the declarations stay. Errors therefore carry the source\'s own paths and a template is held to the whole schema: a `number` variable in a text field, a `date` value that is not a date, or a chart cell that resolves to a list are all reported.\n- `validate(doc, { template: true | false })` overrides the marker, and `validate(doc, { values })` fills before checking.\n- Unused variables warn (`declared but never used`), as do tokens naming undeclared ids.\n- A variable with a `value` is a default: filling overrides it, leaving it blank in a data row keeps it.\n\nA template is a normal OPF file for everything else: the editor opens it, `opf validate` and `opf edit` accept it, pagination and bundling work on it, and renderers preview it.\n\n### Placeholder content\n\n"Empty slots the author must fill" are required variables. There is no separate placeholder construct. A slide, block or field that should not exist unless supplied uses an optional variable (`required: false`): its `var:id` field is omitted when the variable is unfilled.\n\n## Resolution\n\n```ts\nresolveVariables(presentation, values?, options?) => {\n presentation, // the concrete deck\n diagnostics, // { code, severity, path, id, message }[]\n unfilled, // required variable ids with no value\n examplesUsed, // ids filled from their `example`\n complete, // unfilled.length === 0\n}\n```\n\nPure and deterministic: no clock, locale, time zone, network, file access or randomness, and no model call. The function never invents content. A variable\'s value comes from `values`, then from the declaration\'s `value`, and only with `options.examples` from its `example`. The input is never mutated; unchanged parts of it are shared with the result, so treat the result as immutable.\n\n| Option | Effect |\n| --- | --- |\n| `examples` | Use each unfilled variable\'s `example` (template previews). |\n| `partial` | Allow unfilled required variables (informational). Their tokens and references stay as written and their declarations stay in the output, so a second pass can finish the deck. |\n| `template` | Override the root marker: a template treats unfilled variables as informational. |\n| `strict` | Throw `OPFVariableError` when any diagnostic has severity `error`. |\n\nThe result of a complete pass:\n\n- Content variables are substituted and their declarations removed, so resolving the result again changes nothing (no double expansion, no re-interpreted `\\{{`).\n- Color variables keep their declarations and their `var:` references (the renderers and the exporter resolve them as before), with values from `values` or `example` written in.\n- The root `template` marker is removed.\n- The result validates as an ordinary deck.\n\nValues are coerced per kind, so data files work as they are: number accepts a finite number or a strict decimal string (`"1250000"`, not `"1,250,000"`); date accepts `YYYY-MM-DD` or an ISO date-time (the date part is kept, no zone conversion); list accepts an array, or a string split on newlines; text accepts a string, a number, a boolean or runs; image accepts a string or `{src}`; url accepts http, https, mailto and tel; color accepts hex. `null`, `undefined` and, for every kind but text, a blank string mean "not provided". A rejected value is an `error` diagnostic (`variable-invalid-value`) and falls back to the declaration. A value for an undeclared variable is a `variable-unknown-value` warning.\n\nDiagnostic codes: `variable-unfilled` (error in a deck, info in a template or partial fill), `variable-invalid-value`, `variable-format` (an unusable number or date pattern), `variable-unknown` (undeclared token), `variable-unknown-value`, `variable-unused`, `variable-example-used`, `variable-rich-flattened`.\n\n`listVariables(presentation, values?)` returns each declaration with `filled` and every place it is used (`uses: [{ path, form: \'token\' | \'reference\' }]`). Fill forms, agents and the editor read it.\n\n## Previews and export\n\nThe renderer, the PPTX exporter and the editor call `resolveVariables` at their entry points, so preview and export agree on text, numbers, dates, images and lists:\n\n- `renderSvg`, `renderSlideSvg`, `resolvePresentation` and `toPptx` accept `variables` (the values) and resolve the deck first when it uses content variables or is a template. A deck without them is untouched, byte for byte.\n- A template is previewed and exported with each unfilled variable\'s `example`, and reports `variable-example-used` through `onDiagnostic` (PPTX) so the sample content is never silent. A variable with no example keeps its `{{id}}` text visible; nothing is made up.\n- A normal deck with an unfilled required variable is refused: `OPFRenderError` or `OPFPptxError` with code `unfilled-variables`.\n- `variables: false` draws the document as authored, tokens and `var:` references visible (the editor canvas\'s view of a template).\n- A core older than this feature (no `resolveVariables`) leaves variable-free decks working and rejects a deck that uses content variables at validation, as any unknown schema construct is rejected.\n\nThe PPTX file contains the resolved text. The template form is not stored in the package, so importing the PPTX returns the filled deck, not the template (see [Follow-ups](#follow-ups)).\n\n## Decks from data: `opf fill`\n\n```\nopf fill <template|-> [--data <values.json|data.csv|data.tsv|->] [--format csv|tsv|json]\n [--delimiter <c>] [--no-header]\n [--output <file|-> | --out-dir <dir> [--name <pattern>] | --combine --output <file|->]\n [--partial] [--examples] [--force] [--fail-on <level>]\n```\n\n- **Records.** A JSON object is one record. A JSON array of objects, a CSV or TSV file, `{columns, rows}` and a row matrix give one record per row; CSV and TSV columns are matched to variable ids by header name. JSON keeps rich values (text runs, lists, Asset objects); CSV and TSV cells are strings that coerce per kind. A blank cell keeps the declared value.\n- **Outputs.** One record gives one deck (`--output`, default stdout). Several records need `--out-dir` (one `<name>.opf.json` per record; `--name` is a pattern with `{n}`, the zero-padded index, and `{column}`, a slug of that column\'s value; default `deck-{n}`) or `--combine` (one deck whose slides are the filled slides of every record in order, with repeated slide ids suffixed by the record index; deck-level fields come from the first record).\n- **Failure.** An unfilled required variable, a value of the wrong kind or an unusable format fails with exit code 1 and the offending record and variable, before any file is written. `--partial` allows unfilled variables and keeps their declarations; `--examples` fills them from their example.\n- **Overlap with `import-data`.** `import-data` turns tabular data into a table or chart *content* payload and shares the same CSV/TSV/JSON parser. `fill` maps rows onto *variables* of a deck the author designed. They compose: a chart whose rows come from a data file stays `import-data`; a chart cell that varies per client is a `var:` reference.\n- Relative image paths in data are written as given, so they resolve against the OPF file\'s final location. Prefer HTTPS URLs, data URIs, `asset:` ids or absolute paths in data files, or write decks next to their images.\n\n## Editor\n\nThe editor package exposes the fill model (`@openpresentation/opf-editor/templates`) and a **Fill template** panel (`/template-panel`, mounted by the playground): variables listed with typed inputs (text, multi-line text, number, date, color, URL, list, and an image source with file upload or `asset:` pick), required and filled state, where each is used, a live preview that re-resolves on every change, and a count of unfilled variables. Inserting a variable token into a text field (declaring a new variable in the same edit when asked) is an edit through the session, so it validates and undoes like any other. Filling replaces the document with the concrete deck as one undoable edit. The canvas draws a template as authored (renderer option `variables: false`), so tokens stay visible and an inline edit never overwrites one; the panel\'s preview draws the resolved deck. See the editor README.\n\n## Decisions\n\nEach is vetoable; the alternative says what changing it would cost.\n\n1. **Token syntax `{{id}}` with ids matching the existing variable id pattern.** Familiar from Mustache, Handlebars and Jinja, readable in JSON, and not a valid identifier in prose. Alternatives: `${id}` (collides with template literals in code), `[[id]]`, dotted paths such as `{{client.name}}` for user variables (no nesting exists in the variable model; nested records are a follow-up). Dotted names are reserved for the [built-in variables](#built-in-variables), which is why a user id can never contain a dot.\n2. **Escape `\\{{`.** One rule with one sequence. `{{{{` doubling was rejected because it is ambiguous next to a real token.\n3. **Whole-field `var:id` carries typed values; inline `{{id}}` always makes text.** One reason each: a chart cell needs a number, a title needs a string. `var:` already existed for colors.\n4. **No `{ "var": "id" }` TextRun form.** Tokens inside a run\'s `text` give the same styling (`{ "text": "{{client}}", "bold": true }`) without a schema change to `TextRun`, and rich values travel through `var:id`. A second form would double the surface every consumer must handle.\n5. **Kinds: color, text, number, date, image, url, list.** `boolean` and nested record or table kinds are not included: no field consumes a boolean, and per-row structure belongs to `import-data` and the follow-up `repeat` construct.\n6. **A root `template: true` marker plus a `validate` option**, not an implied notion ("has unfilled variables"). An explicit marker keeps the strictness for normal decks (an unfilled variable cannot slip through) and survives in the file for editors and the gallery.\n7. **`value` is both the default and the current value; `example` is only illustration.** A third `default` field was rejected as redundant. `required` defaults to `true`.\n8. **Undeclared tokens stay literal.** Existing decks with `{{` in text, or code samples, are never altered.\n9. **Content declarations are consumed by resolution; color declarations stay.** Idempotence and backward compatibility respectively.\n10. **Renderers and the exporter call `resolveVariables`** (rather than require resolved input), so the editor can pass its in-progress values straight to the preview. Templates preview with examples; decks with unfilled required variables are refused at export.\n11. **No template provenance in PPTX in this release.** The package stores the resolved deck. Re-import returns the filled deck. Storing the template form needs a provenance part and a re-import path; recorded as a follow-up.\n12. **`opf fill`: a deck per record, plus `--combine`; no `repeat` construct yet.** A slide marked `repeat` (one instance per row inside one deck, with shared slides emitted once) is the natural next step but needs a schema addition and a per-record scope; `--combine` covers "one slide group per record" without it.\n13. **English number and date formats only.** Separators and names are fixed so output never depends on host locale; a `locale` field is a follow-up.\n14. **Optional unfilled variables vanish; required ones never do.** The resolver does not guess a replacement for a missing value.\n\n15. **Built-ins are dotted and read from the document, not declared.** A dot can never appear in a user id, so there is no collision rule to learn. The cost is one non-dotted name, `speakers`, which is reserved. Per-slide values are not built-ins because they need composition-time resolution and native PPTX fields.\n16. **A missing built-in source resolves to nothing with a warning (previews of a template keep the token).** An unknown path is an error because it is a typo, not missing data.\n\n## Limits\n\n- A variable\'s own `value` is taken literally: a token inside a value (`{{other}}`) is not expanded, so variables do not refer to each other.\n- Variables are not computed: no arithmetic, conditionals or loops. A deck that needs them generates its data upstream.\n- A variable cannot change structure beyond splicing a list into an array and omitting an optional field. Which slides exist is the author\'s choice (or `opf fill`\'s per-record decks).\n- Variables that point at relative image paths depend on where the filled file is saved.\n- Schema support is not fidelity: the renderer and exporter lay out the resolved deck, so a long substituted value can overflow like any long authored value. Check the filled deck with the usual composition diagnostics.\n\n## Follow-ups\n\n- A `repeat` slide construct and per-record scoping for one-deck-many-records fills.\n- Template provenance in PPTX export (re-import returns the template), and importing a PowerPoint deck with `{{id}}` tokens as a template.\n- Locale-aware number and date formats; conditional visibility of a block.\n- pptx.gallery: a template gallery and "use this template" flow; the gallery\'s examples gain optional variables.\n- Image variables backed by a host asset picker in hosts other than the playground.\n- A JSON Schema for a template\'s values file, generated from `listVariables`, for agents and form builders.\n'
320
320
  },
321
321
  {
322
322
  "slug": "validate",
323
323
  "file": "docs/validate.md",
324
324
  "title": "OPF validate: one checker for format, references, accessibility, layout and content",
325
- "markdown": "# OPF validate: one checker for format, references, accessibility, layout and content\n\n`validate` in `@openpresentation/opf` and `opf validate` in the CLI check an OPF presentation for everything a tool can tell without an opinion: whether it is well-formed OPF, whether everything it points at resolves, whether a host's house rules hold, and the problems a schema cannot see, such as text that is hard to read, text that does not fit, pictures without alt text, content read out of order, fonts outside the deck's scheme, placeholder text and slides left empty. It is the only checker. Releases before 0.14 had several (a schema validator, a lint and an audit, in the library and the CLI); 0.14 replaces them all with `validate`.\n\n`validate` is read-only, local and deterministic. It fetches nothing (no images, fonts or catalogs), consults no clock and calls no model. The same document, options and fonts give byte-identical findings.\n\n```sh\nopf validate deck.opf.json # everything, JSON report\nopf validate deck.opf.json --format text # one line per finding\nopf validate deck.opf.json --only format,references --fail-on warning\nopf validate deck.opf.json --ignore layout --config house-rules.json\nopf validate --list-rules\n```\n\n```js\nimport { validate } from '@openpresentation/opf';\n\nconst report = validate(presentation); // a parsed presentation\nconst fromText = validate(fileText); // strict JSON text: also syntax, duplicate keys and line/column\nvalidate(presentation, { only: ['format'] }); // the cheap check: schema and semantic rules only\nvalidate(presentation, { ignore: ['layout'] }); // everything except the checks that build slide layouts\nvalidate(presentation, { contracts, severity: { 'opf/text-contrast': 'error' } }); // house rules\n```\n\n## Categories\n\nEvery finding has one of six categories. They say what kind of question the finding answers, and they select what runs.\n\n| Category | Asks | Errors by default | Examples |\n| --- | --- | --- | --- |\n| `format` | Is it well-formed OPF? | yes | JSON syntax, duplicate keys, schema, a citation of an unknown reference, an unknown dataset, a required variable with no value (a warning in a template) |\n| `references` | Does everything it points at resolve? | missing or circular asset, invalid catalog record; unknown or deprecated catalog id and unused dataset are warnings | `asset:` references, catalog ids, citations and datasets nobody uses |\n| `policy` | Does it follow the host's house rules? | the host sets it (default `error`) | \"only these layouts\", brand fonts: the `contracts` option |\n| `accessibility` | Can everyone read it? | no: warning and info | contrast, alt text, slide titles, reading order, link text, language |\n| `layout` | Will it present as authored? | no: warning | overflow, minimum type size, image resolution, fonts outside the scheme |\n| `content` | Is anything left unfinished? | no: warning and info | placeholder text, an empty slide, a chart value that is text |\n\n`valid` keeps meaning \"correct OPF\": it is true when no finding has severity `error`, and by default only `format`, `references` and `policy` can produce one. A low-contrast deck is still a valid deck. A team promotes the rules it cares about with `severity`.\n\nEvery rule is listed, with its category, default severity, cost and the basis it rests on, in [Rules](#rules) below. A rule stays in core only when it has a measurable basis (a standard such as WCAG 2.2, a print-preflight check, or a number computed from the layout); judgments that need an audience and a purpose, such as how many fonts or words a deck should have, are not rules here.\n\n## Input and report\n\n`validate(input, options?)` takes a parsed presentation (an object) or **strict JSON text**. A string is read as JSON and nothing else: it is never sniffed for YAML or Markdown. When the text begins like YAML (`key: value`, `---`) or Markdown (`#`), the `help` of the syntax finding says to read it with `fromYaml` or `fromMarkdown` first, which return a presentation and call `validate` for you.\n\nWith text, a syntax error or a duplicate key comes back with `location` (original-source UTF-16 offset and length, one-based line and column), and so does every finding about a value in the document; a finding about a missing field is located at the object that lacks it. Text that is not JSON reports its syntax errors (`opf/json-syntax`) whatever `only` says, because nothing else can run.\n\nThe report is a [`FindingReport`](finding-schema-reference.md) (`spec/schemas/finding.schema.json`) with a few more fields:\n\n| Field | Meaning |\n| --- | --- |\n| `valid` | No finding has severity `error` |\n| `findings` | Every finding, ordered by slide (document-level findings first), then category, then rule |\n| `counts` | `{ error, warning, info }` |\n| `schemaValid` | The document passes the schema and the semantic format checks; `null` for text that is not JSON. A required variable with no value does not make a document schema-invalid |\n| `checks` | What ran and what was not measured (below) |\n| `template`, `unfilledVariables` | Documents that declare content variables or are templates: whether they were checked as a template, and which required variables have no value |\n\nA finding has a stable `ruleId` (`opf/text-contrast`), a `severity` (`error`, `warning`, `info`), a `category`, a JSON Pointer `path`, a `message`, a `help` sentence and a `definition` link to the rule below or to the schema constraint. It may carry `slide` and `slideId` (zero-based index and id of the slide it belongs to), `location` (from text), `measured` (the numbers behind it: ratios, sizes, counts), `suggestions` (catalog records that exist, never silently applied), `lookup` (arguments for `opf schema` or `opf catalog`, not a shell string), `validation` (the raw schema issue, with every union alternative) and `fixes`.\n\n`checks` says what each part did: `syntax` (`checked` for text), `schema`, `references`, `policy`, `accessibility` and `content` are `checked` or `not-run`; `layout` is `not-run`, `estimated` (text widths are core's portable estimate) or `measured` (the host passed `fonts.textMeasurement`); background picture pixels are never read (`backgroundPixels`), image resolution reads only embedded `data:` images (`imageBytes`), and native PowerPoint rendering is not checked (`nativeExport`).\n\nA document the schema rejects is not audited: its accessibility, layout and content rules do not run, and its format errors are reported even when `only` did not ask for them, so the report says why nothing else ran (switch them off with `ignore: ['format']`).\n\n## Options\n\n```js\nvalidate(presentation, {\n only: ['format', 'opf/missing-alt-text'], // run just these rules or categories\n ignore: ['layout'], // do not run these\n severity: { 'opf/text-contrast': 'error', content: 'off' }, // per rule or category: error, warning, info or off\n ignorePaths: [{ rule: 'opf/text-contrast', path: '/slides/3' }], // an accepted exception, by JSON Pointer prefix\n catalogs: loadedCatalogRecords, // records the host already loaded; nothing is fetched\n contracts, // host policy (below)\n thresholds: { contrastNormal: 7 }, // see DEFAULT_VALIDATION_THRESHOLDS\n fonts, // the host's fonts: layout rules read fonts.textMeasurement\n chartPalette: ['#0072B2', '#E69F00'], // the engine's chart colours\n template: true, values: { client: 'Acme' }, // check as a template, or fill variables first\n});\n```\n\n- **Names.** `only`, `ignore`, `severity` and `ignorePaths` take full rule ids (`opf/text-contrast`), bare rule names (`text-contrast`) or category names (`accessibility`). No rule is named like a category. A rule beats its category in `severity`. An unknown rule, category, threshold or option throws a `TypeError`, so a typo cannot silently disable a check.\n- **`fonts`.** An object with a `textMeasurement` (the renderer's `loadFonts()` result has one). Without it the layout rules use core's portable estimate and `checks.layout` says `estimated`; with it, `measured`. `textMeasurement` may also be a function of the zero-based slide index, for a script-aware measurement per slide.\n- **`template` and `values`.** A deck that declares content variables is checked as the deck it resolves to; see [templates and variables](templates-and-variables.md).\n- **Thresholds.** `DEFAULT_VALIDATION_THRESHOLDS` holds `contrastNormal`, `contrastLarge`, `minFontSizePt`, `minImagePpi` and `minSeriesColorDifference`; each rule below lists the ones it reads.\n\n`validationRules` lists every rule with its metadata (`id`, `name`, `category`, `severity`, `cost`, `summary`, `rationale`, `standard`, `approximations`, `thresholds`), and `findValidationRule(name)` finds one by id or bare name.\n\n`assertValid(value, options?)` throws `OPFValidationError`, carrying the `report` and its error `findings`, unless `validate(value, options)` is valid, and tells TypeScript that `value` is a `Presentation`. It takes a parsed value, not text.\n\n`validateCatalogRecord(kind, record)` and `assertValidCatalogRecord(kind, record)` check one catalog record (an audience, theme, layout, font scheme and so on) against its schema. The report has the same shape; its findings are `format`, and a record that links to an unknown id only warns.\n\n## Cost\n\n`validate` is lazy: it builds only what the rules you ask for need. Measured with Node 24 on three bundled example decks (milliseconds per call, warm; your machine will differ, the ratios hold):\n\n| Call | `minimal` (3 slides) | `renewal-save-plan` (6 slides, 34 KB) | `broadband-public-hearing-deck` (10 slides, 41 KB) |\n| --- | --- | --- | --- |\n| `only: ['format']` | 0.06 | 0.23 | 0.64 |\n| `only: ['format', 'references']` | 0.7 | 5.2 | 7.6 |\n| everything (the default) | 1.7 | 24 | 32 |\n\n`only: ['format']` costs what the schema check alone costs; it builds no layout. The references walk reads the schema alongside the document and costs about ten times as much. The rules of `layout` (and `opf/text-contrast`, `opf/text-on-image` and `opf/reading-order`) compose every slide and are most of the cost of a full run. Each rule's `cost` says which it needs: `syntax` reads the JSON text, `structure` reads the document alone, `composition` builds slide layouts.\n\nThe default runs everything, so an agent or CI job never gets \"valid\" for a deck that overflows, and the hot paths say what they need: pagination, conversion, patch validation and the editor's per-edit check use `{ only: ['format'] }`; the CLI's write, render, export and import commands use `{ only: ['format', 'references'] }`, so a contrast warning never blocks an export.\n\n## Quick fixes\n\nA fix is a suggestion that core never applies. A host such as the editor's Review panel shows it and applies it through its own undoable edit path.\n\n- A `patch` fix (`kind: \"patch\"`, the default) carries RFC 6902 JSON Patch operations in `patch` (`add`, `remove`, `replace`, `move`, `copy`, `test`, with JSON Pointer paths). `safe: true` means the change cannot alter what the content says: for example switching a failing text colour to the colour the theme chose for that background.\n- A `focus` fix (`kind: \"focus\"`) names the field the author must fill in (`focus.path`, `focus.field`: `alt`, `title`, `text`, `link`, `language`, `fontSize`).\n- `safe: false` patches change meaning or appearance and need a deliberate click, for example marking a picture decorative with an empty `alt`.\n\n`title` says what applying the fix does.\n\n## Catalog context and contracts\n\nCatalog ids are resolved against the document's own `catalogs` records, then the records the host passed in `catalogs`, then the bundled catalogs. An unknown id is a warning (`opf/catalog-reference`), never an error, and engine-defined layouts may be valid; a `source` that names an external catalog is reported as not fetched (`opf/catalog-source`) and, unless the host loaded that source's records, the ids it may define are not judged. Free-form audience and purpose descriptions, arbitrary `extensions` data, and language strings in [BCP-47 syntax](https://www.rfc-editor.org/rfc/rfc5646.html#section-2.1) (regional, extended, private-use and grandfathered forms) do not become catalog references because of their spelling; an explicit `language.id` is still a catalog reference. Supplied and inline catalog records are validated, and a repeated id or an invalid override is an error (`opf/catalog-record`). Suggestions name records actually present in the checked context.\n\nAn `asset:` reference that names no entry of the document's `assets` registry is an error (`opf/asset-reference`), and so is a cycle of `asset:` sources (`opf/asset-cycle`); resource bytes are never fetched. Chart and table data findings keep their code as the rule id (`opf/dataset-unknown`, `opf/chart-value-not-numeric`, ...), and a `opf/chart-value-not-numeric` cell whose column is written in one display style (`\"12%\"`, `\"$1,234\"`) carries `fixes` ([chart and table data](chart-table-data.md#migration-help)). Citation and caption errors keep theirs too (`opf/cite-unknown-reference`, `opf/reference-id-duplicate`, `opf/cite-unsupported-location`, `opf/caption-unsupported-payload`).\n\nA custom narrative is an inline `catalogs.narratives.records` entry, and `narrative` is always a string. When the narrative resolves locally, a `slides[].beat` id it does not define is `opf/unknown-beat`, a root `duration` outside the record's `duration` range is `opf/duration-outside-narrative`, an inline record whose `duration.min` exceeds `duration.max` is `opf/narrative-duration-range`, and a beat no slide references is `opf/unused-beat` (only once some slide names a beat). A slide-level `design.theme` whose resolved dimensions differ from the deck's is `opf/slide-theme-dimensions`: a PPTX has one slide size, and a slide's `design` cannot set `dimensions` at all (a schema error). An undeclared `var:<id>` in a solid, gradient-stop or pattern background colour warns like any other colour reference (`opf/variable-reference-unknown`).\n\nHost policy comes from explicit options, never from the document: fields under `extensions` are data and cannot install policy. `contracts` and `catalogs` may live in a local JSON file for the CLI (`--config`).\n\n```json\n{\n \"catalogs\": {\n \"layouts\": [\n {\"id\":\"partner-title\",\"name\":\"Partner title\",\"placeholders\":[{\"type\":\"title\"}]}\n ]\n },\n \"contracts\": [\n {\n \"path\":\"/slides/*/layout\",\n \"allowedValues\":[\"partner-title\",\"text-1x\"],\n \"message\":\"Use the brand layouts {{allowed}} at {{path}}. See {{file}}.\",\n \"documentation\":\"brand-guide.md#layouts\",\n \"severity\":\"error\"\n }\n ]\n}\n```\n\nContract paths are JSON Pointer patterns: `~0` escapes `~`, `~1` escapes `/`, and a whole `*` segment matches one property or array index. Contracts check existing fields; they do not require an omitted field or insert defaults. Allowed values are JSON primitives. Optional message placeholders are `{{path}}`, `{{value}}`, `{{allowed}}`, and `{{file}}`. A contract's severity is `error` unless it says otherwise. Invalid or misspelled configuration keys fail instead of being ignored. Messages and catalog labels are data, not executable instructions.\n\n## The CLI\n\n`opf validate <file|-> [--config <file>] [--only <list>] [--ignore <list>] [--fail-on <error|warning|info>] [--format <json|text>] [--list-rules]`\n\nThe JSON report (the default) is the `validate` report plus the source's SHA-256 (`sha256`), the bundled core version (`opfVersion`) and, with `--config`, the configuration file's path and hash (`context`). `--format text` prints one line per finding (`file:line:column severity rule message`), the JSON Pointer path, a hint and any fix, then a summary. `--list-rules` prints every rule (`--format json` with its full metadata).\n\nA file ending `.yaml` or `.yml` (or stdin with `--input-format yaml`) is read as YAML through `fromYaml`, so the report is the same and every finding is located in the YAML; a YAML syntax or dialect error is a `yaml/<rule>` finding (`format`, exit 1).\n\n`--only` and `--ignore` take comma-separated rule ids, bare names or category names. `--config` takes an explicit local JSON file, never `-`, holding `{ catalogs, contracts, severity, only, ignore, ignorePaths, thresholds, chartPalette }`; `--only` replaces the file's `only` and `--ignore` adds to its `ignore`.\n\n| Exit | Meaning |\n| --- | --- |\n| 0 | No finding at or above `--fail-on` (default `error`) |\n| 1 | At least one finding at or above `--fail-on`, or a document that is not valid JSON (or YAML) |\n| 2 | Usage, configuration or I/O error |\n\n`--fail-on` is the one way to say \"fail on warnings\" on every command that checks a document: `--fail-on warning`. Other commands (`create`, `edit`, `fill`, `paginate`, `bundle`, `import-data`, `render`, `export`, `import`, `from-md`, `from-yaml`) check only `format` and `references` of what they write and take `--fail-on` the same way.\n\n## How contrast is computed\n\n`opf/text-contrast` reasons about the colours the preview draws, because a check against a different colour than the one on screen is worse than none.\n\n1. **Background.** Design resolves per field: slide design, deck design, theme, engine default. The background is a theme slot or colour (`solid`), the card surface for content on a `contentBox` card, a table cell's fill, a gradient or a pattern. Solid, gradient-stop and pattern colours are read as literal hex colours, as the preview reads them (anything else falls back as it does there). Translucent backgrounds are composited over white.\n2. **Text colour.** Titles, body, lists and tags use the scheme's `dark1`, or `light1` when the background is dark (luminance below 0.179). Furniture and quote attributions use the muted colour, metric values the primary colour, and explicit run and table colours the author's `ColorRef` resolved the same way the renderer resolves it. As the preview does, a gradient background has no single colour and counts as light, so a dark gradient behind default text is reported.\n3. **Gradients.** The gradient is sampled on a 5 by 5 grid over the area the text covers (the lines' measured ink, aligned as the text is), using the preview's gradient geometry, and the worst colour decides. A pattern contributes both of its colours.\n4. **Pictures.** Pixels are never read. A background picture is bounded by a grey ramp from black to white, composited through the picture opacity and any full-frame `design.slideImage.overlay`. Text passes only if every step passes; otherwise `opf/text-on-image` says the result cannot be guaranteed. An overlay limited to an edge band is not counted.\n5. **Ratio and size.** The WCAG 2.x relative-luminance contrast ratio is compared with 4.5:1, or 3:1 for large text: at least 18 pt, or 14 pt and bold. Sizes are the fitted sizes, expressed at the 13.33 by 7.5 in reference slide (96 px per inch). The default body size, 18.75 pt, is large text under WCAG, so default body text passes at 3:1; set `contrastLarge` to 4.5 for a stricter policy.\n\nApproximations: anti-aliasing, text shadows, font weight and the exact glyph coverage are not modelled; chart and code text are not checked (charts pick label colours against their surface; code uses its own panel colours). See each rule below for what it cannot see.\n\n## Layout rules use composition\n\nOverflow, minimum type size, reading order and image resolution use `composeSlide` at the deck's slide size with the resolved font scheme, the same composition the preview and the PPTX export consume. Without `fonts.textMeasurement` the text widths are core's portable estimate and can differ from the real fonts by a few percent; hosts that load fonts (the editor, the renderer) pass their measurement for font-exact results. A composition with `overflow: \"error\"` fails at composition time; `validate` still computes the geometry, relaxes the setting, and reports the overflow as a **warning**, so that `valid` never depends on font metrics. Rendering and export still refuse such a slide when it does not fit, and a host that wants the finding to fail promotes it with `severity: { 'opf/text-overflow': 'error' }`.\n\nPromoted region keys (`left`, `center`, `right`, `top:left` and the rest) are composed in visual reading order (rows from top to bottom, then along the row), whatever order the keys are written in. The PPTX export writes shapes in that order, which is the reading order of assistive technology; `opf/reading-order` checks that the composed order still matches the geometry (the same `visualReadingOrder` that composes the regions). Authoring with `blocks` (an ordered list) keeps the reading order equal to the visual order.\n\n## Templates and variables\n\n`opf/variable-unfilled` is one rule for a required variable that has no value. In a deck that is not a template it is an error (the deck is incomplete), so `valid` is false; in a template (`\"template\": true` in the document, or `template: true` in the options) it is a warning, because a template is incomplete on purpose; `unfilledVariables` lists the ids either way. In a document that declares no content variables, a `{{id}}` token left in a string (outside `variables`, `extensions`, `catalogs` and `assets`) is a warning. A `var:` colour that names no declared variable is `opf/variable-reference-unknown`. It is a `format` rule, so `only: ['format']` (what pagination, patching, conversion and every write command run) rejects a deck with an unfilled required variable. Fill a template with `opf fill` or `resolveVariables`, or pass `values`, to clear the findings.\n\n## Rules\n\nThe reference below is generated from the rule registry (`validationRules`); `node scripts/build-validate-docs.mjs --check` (and a core test) fails when it drifts.\n\n<!-- validate-rules:start (generated by scripts/build-validate-docs.mjs; do not edit by hand) -->\n\n| Rule | Category | Default | Cost | What it reports |\n| --- | --- | --- | --- | --- |\n| [`opf/json-syntax`](#opfjson-syntax) | Format | error | syntax | The text is not strict JSON. |\n| [`opf/duplicate-key`](#opfduplicate-key) | Format | error | syntax | A JSON object repeats a key. |\n| [`opf/schema`](#opfschema) | Format | error | structure | The document breaks the OPF schema or a structural rule the schema cannot express. |\n| [`opf/reference-id-duplicate`](#opfreference-id-duplicate) | Format | error | structure | Two references share an id. |\n| [`opf/cite-unknown-reference`](#opfcite-unknown-reference) | Format | error | structure | A citation names a reference that does not exist. |\n| [`opf/cite-unsupported-location`](#opfcite-unsupported-location) | Format | error | structure | A citation or footnote sits where no engine draws it. |\n| [`opf/caption-unsupported-payload`](#opfcaption-unsupported-payload) | Format | error | structure | A caption is on a payload that cannot carry one. |\n| [`opf/dataset-unknown`](#opfdataset-unknown) | Format | error | structure | A chart or table names a dataset that does not exist. |\n| [`opf/dataset-field-unknown`](#opfdataset-field-unknown) | Format | error | structure | A table asks a dataset for a column it does not have. |\n| [`opf/data-column-duplicate`](#opfdata-column-duplicate) | Format | error | structure | A dataset or table has two columns with the same name. |\n| [`opf/chart-mapping-unknown-column`](#opfchart-mapping-unknown-column) | Format | error | structure | A chart mapping names a column the data does not have. |\n| [`opf/number-format-invalid`](#opfnumber-format-invalid) | Format | error | structure | A number format cannot be read. |\n| [`opf/variable-invalid-value`](#opfvariable-invalid-value) | Format | error | structure | A variable value does not match its declared type. |\n| [`opf/variable-unknown-builtin`](#opfvariable-unknown-builtin) | Format | error | structure | A token names a built-in variable that does not exist. |\n| [`opf/chart-highlight-unknown-name`](#opfchart-highlight-unknown-name) | Format | error | structure | A chart highlight names a series or category the chart does not have. |\n| [`opf/variable-format`](#opfvariable-format) | Format | error | structure | A variable cannot be formatted. |\n| [`opf/variable-unfilled`](#opfvariable-unfilled) | Format | error | structure | A required template variable has no value. |\n| [`opf/run-color-unrecognized`](#opfrun-color-unrecognized) | Format | warning | structure | A text run colour is none of the documented forms. |\n| [`opf/numbering-start-ignored`](#opfnumbering-start-ignored) | Format | warning | structure | A list entry sets `start` where nothing is numbered. |\n| [`opf/asset-reference`](#opfasset-reference) | References | error | structure | An `asset:` reference names an asset that is not in the registry. |\n| [`opf/asset-cycle`](#opfasset-cycle) | References | error | structure | Asset references form a cycle. |\n| [`opf/catalog-record`](#opfcatalog-record) | References | error | structure | A supplied or inline catalog record is invalid, or repeats an id. |\n| [`opf/catalog-reference`](#opfcatalog-reference) | References | warning | structure | A catalog id is not in the available context. |\n| [`opf/deprecated-catalog-id`](#opfdeprecated-catalog-id) | References | warning | structure | A catalog id is deprecated. |\n| [`opf/catalog-source`](#opfcatalog-source) | References | info | structure | An external catalog source was not fetched. |\n| [`opf/unused-reference`](#opfunused-reference) | References | warning | structure | A reference is never cited. |\n| [`opf/unused-dataset`](#opfunused-dataset) | References | warning | structure | A dataset is never used. |\n| [`opf/unknown-beat`](#opfunknown-beat) | References | warning | structure | A slide names a beat its narrative does not define. |\n| [`opf/slide-theme-dimensions`](#opfslide-theme-dimensions) | References | warning | structure | A slide theme sets a slide size that differs from the deck. |\n| [`opf/variable-unknown`](#opfvariable-unknown) | References | warning | structure | A `{{token}}` names a variable that is not declared. |\n| [`opf/variable-unknown-value`](#opfvariable-unknown-value) | References | warning | structure | A value was supplied for a variable that is not declared. |\n| [`opf/variable-unused`](#opfvariable-unused) | References | warning | structure | A variable is declared but never used. |\n| [`opf/variable-reference-unknown`](#opfvariable-reference-unknown) | References | warning | structure | A `var:` colour names a variable that is not declared. |\n| [`opf/contract`](#opfcontract) | Policy | error | structure | A field is outside the values a host policy allows. |\n| [`opf/text-contrast`](#opftext-contrast) | Accessibility | warning | composition | Text colour has too little contrast against the background it sits on. |\n| [`opf/text-on-image`](#opftext-on-image) | Accessibility | info | composition | Text sits on a background picture whose pixels cannot be measured. |\n| [`opf/missing-alt-text`](#opfmissing-alt-text) | Accessibility | warning | structure | A picture has no alt text and is not marked decorative. |\n| [`opf/poor-alt-text`](#opfpoor-alt-text) | Accessibility | info | structure | Alt text is a file name, a URL, a generic word or very long. |\n| [`opf/missing-slide-title`](#opfmissing-slide-title) | Accessibility | warning | structure | A slide has no title. |\n| [`opf/duplicate-slide-title`](#opfduplicate-slide-title) | Accessibility | info | structure | Two slides have the same title. |\n| [`opf/reading-order`](#opfreading-order) | Accessibility | warning | composition | The order content is read differs from the order it appears on the slide. |\n| [`opf/link-text`](#opflink-text) | Accessibility | warning | structure | Link text does not say where the link goes. |\n| [`opf/chart-color-only`](#opfchart-color-only) | Accessibility | info | structure | Chart series may be indistinguishable without colour vision. |\n| [`opf/chart-text-alternative`](#opfchart-text-alternative) | Accessibility | info | structure | A chart has no text alternative, or is marked decorative. |\n| [`opf/missing-language`](#opfmissing-language) | Accessibility | info | structure | The presentation does not declare its language. |\n| [`opf/chart-option-adapted`](#opfchart-option-adapted) | Layout | warning | structure | A chart option cannot be shown by the chart type. |\n| [`opf/text-overflow`](#opftext-overflow) | Layout | warning | composition | Text or a table does not fit its space at the smallest allowed size. |\n| [`opf/unresolved-content`](#opfunresolved-content) | Layout | warning | composition | Content cannot be drawn as authored. |\n| [`opf/layout-failed`](#opflayout-failed) | Layout | warning | composition | The slide layout could not be computed. |\n| [`opf/min-font-size`](#opfmin-font-size) | Layout | warning | composition | Text is drawn smaller than the readable minimum. |\n| [`opf/font-outside-scheme`](#opffont-outside-scheme) | Layout | warning | structure | Text uses a font family that is not in the deck's font scheme. |\n| [`opf/image-resolution`](#opfimage-resolution) | Layout | warning | composition | An image has too few pixels for the size it is shown at. |\n| [`opf/chart-value-not-numeric`](#opfchart-value-not-numeric) | Content | warning | structure | A chart value is text, not a number. |\n| [`opf/chart-mapping-adapted`](#opfchart-mapping-adapted) | Content | warning | structure | A chart mapping entry is ignored. |\n| [`opf/chart-highlight-adapted`](#opfchart-highlight-adapted) | Content | warning | structure | A chart highlight is adapted by the chart type. |\n| [`opf/code-highlight-out-of-range`](#opfcode-highlight-out-of-range) | Content | warning | structure | A code highlight marks a line the code does not have. |\n| [`opf/code-highlight-range-reversed`](#opfcode-highlight-range-reversed) | Content | warning | structure | A code highlight range ends before it starts. |\n| [`opf/variable-builtin-missing`](#opfvariable-builtin-missing) | Content | warning | structure | A built-in variable has no value in this document. |\n| [`opf/duration-outside-narrative`](#opfduration-outside-narrative) | Content | warning | structure | The target duration is outside the range the narrative suits. |\n| [`opf/narrative-duration-range`](#opfnarrative-duration-range) | Content | warning | structure | An inline narrative duration range has min greater than max. |\n| [`opf/placeholder-text`](#opfplaceholder-text) | Content | warning | structure | Placeholder text was left in the deck. |\n| [`opf/empty-text`](#opfempty-text) | Content | info | structure | A text field is present but empty. |\n| [`opf/unused-beat`](#opfunused-beat) | Content | info | structure | A beat of the narrative has no slide that references it. |\n| [`opf/empty-slide`](#opfempty-slide) | Content | warning | structure | A slide has no content at all. |\n\n## Format rules\n\n*Is it well-formed OPF?*\n\n### `opf/json-syntax`\n\nDefault severity: **error**. Cost: syntax. The text is not strict JSON.\n\n**Why.** Every OPF tool reads JSON. A misplaced comma, quote, comment or trailing comma makes the file unreadable, and the range says where.\n\n**Basis.** RFC 8259 (strict JSON: no comments, no trailing commas)\n\n**Approximations.** Only text input is checked. A string is always read as JSON; a text that looks like YAML or Markdown gets a hint to use fromYaml or fromMarkdown first.\n\n### `opf/duplicate-key`\n\nDefault severity: **error**. Cost: syntax. A JSON object repeats a key.\n\n**Why.** JSON.parse keeps only the last value of a repeated key, so the earlier one disappears without a trace. The check compares escaped and literal spellings of the same key.\n\n**Basis.** RFC 8259 section 4 (names within an object should be unique)\n\n### `opf/schema`\n\nDefault severity: **error**. Cost: structure. The document breaks the OPF schema or a structural rule the schema cannot express.\n\n**Why.** The schema is the contract every engine, exporter and editor relies on. A field of the wrong type, a missing required field, an unknown property or content fields that cannot be mixed make the document ambiguous or undrawable.\n\n**Basis.** spec/schemas/opf.schema.json (JSON Schema 2020-12) and the semantic rules of OPF\n\n**Approximations.** All alternatives of a union are reported, with the failing schema location in `validation`. A required variable with no value is reported by `opf/variable-unfilled`, in the same category.\n\n### `opf/reference-id-duplicate`\n\nDefault severity: **error**. Cost: structure. Two references share an id.\n\n**Why.** A citation names a reference by id; a repeated id makes it ambiguous which entry is cited.\n\n**Basis.** spec/schemas/opf.schema.json (JSON Schema 2020-12) and the semantic rules of OPF\n\n### `opf/cite-unknown-reference`\n\nDefault severity: **error**. Cost: structure. A citation names a reference that does not exist.\n\n**Why.** A cited id with no entry in `references` has nothing to show in the footnote area.\n\n**Basis.** spec/schemas/opf.schema.json (JSON Schema 2020-12) and the semantic rules of OPF\n\n### `opf/cite-unsupported-location`\n\nDefault severity: **error**. Cost: structure. A citation or footnote sits where no engine draws it.\n\n**Why.** `cite` and `footnote` apply to runs in text, bullets and list items; table cells, captions, and reference and footnote texts have no marker.\n\n**Basis.** spec/schemas/opf.schema.json (JSON Schema 2020-12) and the semantic rules of OPF\n\n### `opf/caption-unsupported-payload`\n\nDefault severity: **error**. Cost: structure. A caption is on a payload that cannot carry one.\n\n**Why.** A caption belongs to exactly one image, chart, table or video; a group, text or code block has no place to draw it.\n\n**Basis.** spec/schemas/opf.schema.json (JSON Schema 2020-12) and the semantic rules of OPF\n\n### `opf/dataset-unknown`\n\nDefault severity: **error**. Cost: structure. A chart or table names a dataset that does not exist.\n\n**Why.** The data cannot be drawn without the dataset it refers to.\n\n**Basis.** spec/schemas/opf.schema.json (JSON Schema 2020-12) and the semantic rules of OPF\n\n### `opf/dataset-field-unknown`\n\nDefault severity: **error**. Cost: structure. A table asks a dataset for a column it does not have.\n\n**Why.** The column cannot be shown when the dataset has no column of that name.\n\n**Basis.** spec/schemas/opf.schema.json (JSON Schema 2020-12) and the semantic rules of OPF\n\n### `opf/data-column-duplicate`\n\nDefault severity: **error**. Cost: structure. A dataset or table has two columns with the same name.\n\n**Why.** Series, mappings and fields address columns by name, so a repeated name makes them ambiguous.\n\n**Basis.** spec/schemas/opf.schema.json (JSON Schema 2020-12) and the semantic rules of OPF\n\n### `opf/chart-mapping-unknown-column`\n\nDefault severity: **error**. Cost: structure. A chart mapping names a column the data does not have.\n\n**Why.** The series cannot be built from a column that is not there.\n\n**Basis.** spec/schemas/opf.schema.json (JSON Schema 2020-12) and the semantic rules of OPF\n\n### `opf/number-format-invalid`\n\nDefault severity: **error**. Cost: structure. A number format cannot be read.\n\n**Why.** A format code that no engine can parse is drawn as the plain number or fails to export.\n\n**Basis.** Excel number format codes, as far as OPF supports them\n\n### `opf/variable-invalid-value`\n\nDefault severity: **error**. Cost: structure. A variable value does not match its declared type.\n\n**Why.** A number variable given text, or a date given a non-date, cannot be filled into the deck.\n\n**Basis.** spec/schemas/opf.schema.json (JSON Schema 2020-12) and the semantic rules of OPF\n\n### `opf/variable-unknown-builtin`\n\nDefault severity: **error**. Cost: structure. A token names a built-in variable that does not exist.\n\n**Why.** The `deck.`, `speaker.` and `organization.` names (and `speakers`) are reserved for built-in variables; a name under them that is not a built-in can never be filled, so the token would show literally.\n\n**Basis.** spec/schemas/opf.schema.json (JSON Schema 2020-12) and the semantic rules of OPF\n\n### `opf/chart-highlight-unknown-name`\n\nDefault severity: **error**. Cost: structure. A chart highlight names a series or category the chart does not have.\n\n**Why.** `chart.highlight` emphasises marks by name; a name that matches no series or category in the chart data highlights nothing, so the chart is not drawn as authored.\n\n**Basis.** spec/schemas/opf.schema.json (JSON Schema 2020-12) and the semantic rules of OPF\n\n### `opf/variable-format`\n\nDefault severity: **error**. Cost: structure. A variable cannot be formatted.\n\n**Why.** A number or date format that does not apply to the value leaves the token unreadable.\n\n**Basis.** spec/schemas/opf.schema.json (JSON Schema 2020-12) and the semantic rules of OPF\n\n### `opf/variable-unfilled`\n\nDefault severity: **error**. Cost: structure. A required template variable has no value.\n\n**Why.** A variable a deck declares as required, with no value, is template scaffolding: `{{name}}` tokens show up literally and colour variables fall back to a default. A normal deck must be filled, so this is an error and the deck is not valid; a template (`template: true`) is incomplete on purpose, so it is a warning.\n\n**Approximations.** Required variables are those the variable machinery reports as unfilled for the document (declared, no value, not `required: false`, not given by the `values` option). For a document that declares no content variables, `{{id}}` / `{{id|format}}` tokens (`{{` escapes) left in any string outside `variables`, `extensions`, `catalogs` and `assets` are reported as warnings. `var:` colour references with no declaration are reported as `opf/variable-reference-unknown`. Fill a template with `opf fill` or `resolveVariables` to clear the findings.\n\n### `opf/run-color-unrecognized`\n\nDefault severity: **warning**. Cost: structure. A text run colour is none of the documented forms.\n\n**Why.** Run colours are open strings so imported decks stay valid, but a value that is not a hex colour, a colour-scheme name or a `var:` reference falls back to the theme colour.\n\n**Basis.** spec/schemas/opf.schema.json (JSON Schema 2020-12) and the semantic rules of OPF\n\n### `opf/numbering-start-ignored`\n\nDefault severity: **warning**. Cost: structure. A list entry sets `start` where nothing is numbered.\n\n**Why.** A start value only restarts an auto-number; without `numbering` on the payload it has no effect.\n\n**Basis.** spec/schemas/opf.schema.json (JSON Schema 2020-12) and the semantic rules of OPF\n\n\n## References rules\n\n*Does everything it points at resolve?*\n\n### `opf/asset-reference`\n\nDefault severity: **error**. Cost: structure. An `asset:` reference names an asset that is not in the registry.\n\n**Why.** An image, logo or video that points at a missing asset cannot be drawn or exported. The check reads the document's own registry only; no file or URL is fetched.\n\n**Basis.** spec/schemas/opf.schema.json (JSON Schema 2020-12) and the semantic rules of OPF\n\n### `opf/asset-cycle`\n\nDefault severity: **error**. Cost: structure. Asset references form a cycle.\n\n**Why.** An asset whose source points back at itself never resolves to a file.\n\n**Basis.** spec/schemas/opf.schema.json (JSON Schema 2020-12) and the semantic rules of OPF\n\n### `opf/catalog-record`\n\nDefault severity: **error**. Cost: structure. A supplied or inline catalog record is invalid, or repeats an id.\n\n**Why.** A record that fails its catalog schema cannot be resolved, and a repeated id makes the override ambiguous. Invalid records are left out of the lookup, so the references to them are reported too.\n\n**Basis.** spec/schemas/<kind>.schema.json\n\n### `opf/catalog-reference`\n\nDefault severity: **warning**. Cost: structure. A catalog id is not in the available context.\n\n**Why.** A theme, layout, font scheme or other catalog id that no record defines falls back to the engine default. Custom ids are legitimate when the host supplies their records, so this is advisory.\n\n**Approximations.** Resolved against the document's inline records, the records the host passed in `catalogs`, then the bundled catalogs. External sources and `pkg:` records are never fetched. Free-form audience and purpose text, and BCP 47 language tags, are not catalog references.\n\n### `opf/deprecated-catalog-id`\n\nDefault severity: **warning**. Cost: structure. A catalog id is deprecated.\n\n**Why.** A deprecated record still resolves, so nothing breaks, but the replacement is the one that will be maintained.\n\n**Basis.** spec/catalogs `deprecation.replacedBy`\n\n### `opf/catalog-source`\n\nDefault severity: **info**. Cost: structure. An external catalog source was not fetched.\n\n**Why.** A catalog `source` is a URL or package the checker never loads, so ids defined there are not verified.\n\n### `opf/unused-reference`\n\nDefault severity: **warning**. Cost: structure. A reference is never cited.\n\n**Why.** A listed reference that no run cites is not drawn anywhere.\n\n**Basis.** spec/schemas/opf.schema.json (JSON Schema 2020-12) and the semantic rules of OPF\n\n### `opf/unused-dataset`\n\nDefault severity: **warning**. Cost: structure. A dataset is never used.\n\n**Why.** A dataset no chart or table references is not drawn anywhere.\n\n**Basis.** spec/schemas/opf.schema.json (JSON Schema 2020-12) and the semantic rules of OPF\n\n### `opf/unknown-beat`\n\nDefault severity: **warning**. Cost: structure. A slide names a beat its narrative does not define.\n\n**Why.** `slides[].beat` links a slide to a step of the narrative plan. A beat id the narrative does not have links to nothing; nothing is drawn from a beat.\n\n**Basis.** spec/schemas/opf.schema.json (JSON Schema 2020-12) and the semantic rules of OPF\n\n**Approximations.** The narrative resolves offline: the inline catalogs.narratives.records, then the `catalogs` option, then the bundled catalog. A narrative no local source defines is not checked.\n\n### `opf/slide-theme-dimensions`\n\nDefault severity: **warning**. Cost: structure. A slide theme sets a slide size that differs from the deck.\n\n**Why.** A PPTX has one slide size. The size a slide-level theme carries is never used: design.dimensions of the deck (or of its theme) wins in every engine, and without them the exporter stops with mixed slide sizes.\n\n**Basis.** spec/schemas/opf.schema.json (JSON Schema 2020-12) and the semantic rules of OPF\n\n### `opf/variable-unknown`\n\nDefault severity: **warning**. Cost: structure. A `{{token}}` names a variable that is not declared.\n\n**Why.** An undeclared token is shown literally.\n\n**Basis.** spec/schemas/opf.schema.json (JSON Schema 2020-12) and the semantic rules of OPF\n\n### `opf/variable-unknown-value`\n\nDefault severity: **warning**. Cost: structure. A value was supplied for a variable that is not declared.\n\n**Why.** The value is never used.\n\n**Basis.** spec/schemas/opf.schema.json (JSON Schema 2020-12) and the semantic rules of OPF\n\n### `opf/variable-unused`\n\nDefault severity: **warning**. Cost: structure. A variable is declared but never used.\n\n**Why.** A declaration nothing refers to has no effect.\n\n**Basis.** spec/schemas/opf.schema.json (JSON Schema 2020-12) and the semantic rules of OPF\n\n### `opf/variable-reference-unknown`\n\nDefault severity: **warning**. Cost: structure. A `var:` colour names a variable that is not declared.\n\n**Why.** An undeclared colour variable falls back to a default colour.\n\n**Basis.** spec/schemas/opf.schema.json (JSON Schema 2020-12) and the semantic rules of OPF\n\n\n## Policy rules\n\n*Does it follow the host's house rules?*\n\n### `opf/contract`\n\nDefault severity: **error**. Cost: structure. A field is outside the values a host policy allows.\n\n**Why.** A host that needs brand layouts, fonts or other fixed choices states them as contracts. The default severity is error; a contract can set its own. Document metadata never installs policy.\n\n**Approximations.** Only the `contracts` option supplies policy, and it checks existing fields: a contract does not require an omitted field or insert a default.\n\n\n## Accessibility rules\n\n*Can everyone read it?*\n\n### `opf/text-contrast`\n\nDefault severity: **warning**. Cost: composition. Text colour has too little contrast against the background it sits on.\n\n**Why.** Low-contrast text is hard or impossible to read for people with low vision, colour-vision differences, glare on a projector or a poor display. WCAG sets 4.5:1 for normal text and 3:1 for large text.\n\n**Basis.** WCAG 2.2 SC 1.4.3 Contrast (Minimum), level AA\n\n**Thresholds.** `contrastNormal` (default 4.5), `contrastLarge` (default 3)\n\n**Approximations.** Computed on sRGB colours with the WCAG relative-luminance formula, against the background the preview draws: a solid or theme colour, the card surface, a table cell fill, every colour a gradient takes under the text box (sampled on a 5x5 grid, angle respected), or both colours of a pattern. Anti-aliasing, text shadows and font weight are not modelled. Text colour is the preview's (the scheme's text role or dark1 on a light background and light1 on a dark one, chosen from the background luminance, where a gradient or picture background uses the scheme's default background colour), so a default can fail on a dark gradient. A link with no colour of its own is measured in the scheme's hyperlink colour.\n\n### `opf/text-on-image`\n\nDefault severity: **info**. Cost: composition. Text sits on a background picture whose pixels cannot be measured.\n\n**Why.** Contrast over a photograph depends on the photograph. Without a full-frame overlay that guarantees readability for every possible image, the result cannot be certified from the document.\n\n**Basis.** WCAG 2.2 SC 1.4.3 Contrast (Minimum), level AA\n\n**Thresholds.** `contrastNormal` (default 4.5), `contrastLarge` (default 3)\n\n**Approximations.** Core never reads picture pixels. The picture is bounded by a grey ramp from black to white, composited through the image opacity and a full-frame design.slideImage.overlay; the text passes only when every step of that ramp passes. An edge-banded overlay is not counted.\n\n### `opf/missing-alt-text`\n\nDefault severity: **warning**. Cost: structure. A picture has no alt text and is not marked decorative.\n\n**Why.** People using a screen reader get nothing for a picture without alternative text. A picture that is purely decorative should say so with an empty alt (alt: \"\"), which is an explicit, reviewed choice instead of an omission.\n\n**Basis.** WCAG 2.2 SC 1.1.1 Non-text Content, level A\n\n**Approximations.** Checks the alt field of images, video, the slide image, logos (design.logo and each LogoSet variant, organization.logo), header/footer images, quote photos and speaker photos, following asset: references to the assets registry. Whether the text describes the picture well is not judged here (see opf/poor-alt-text). Charts carry `chart.alt` and are checked by opf/chart-text-alternative. Background images and watermarks are decorative by definition and are not checked.\n\n### `opf/poor-alt-text`\n\nDefault severity: **info**. Cost: structure. Alt text is a file name, a URL, a generic word or very long.\n\n**Why.** Alt text such as \"image\", \"IMG_2041.png\" or a 400-character paragraph does not do the job of describing a picture: it is read out and adds noise without information.\n\n**Basis.** WCAG 2.2 SC 1.1.1 Non-text Content, level A\n\n**Approximations.** Pattern checks only: file extensions and camera-style names, a bare generic word, a URL, a leading \"image of\", and more than 250 characters. Chart alt text (chart.alt) is checked too: a bare chart word, a URL, a leading \"chart of\" or more than 250 characters. It cannot tell whether a plausible sentence is accurate.\n\n### `opf/missing-slide-title`\n\nDefault severity: **warning**. Cost: structure. A slide has no title.\n\n**Why.** Slide titles are how people using a screen reader, an outline view or keyboard navigation find and tell slides apart; PowerPoint's own accessibility checker reports a missing title too.\n\n**Basis.** WCAG 2.2 SC 2.4.2 Page Titled and SC 2.4.6 Headings and Labels, level AA (PowerPoint: \"Missing slide title\")\n\n**Approximations.** Only the slide-level title field counts. Text that merely looks like a heading inside a block does not.\n\n### `opf/duplicate-slide-title`\n\nDefault severity: **info**. Cost: structure. Two slides have the same title.\n\n**Why.** Identical titles make slides indistinguishable in an outline or a screen reader's slide list.\n\n**Approximations.** Titles are compared case-insensitively with whitespace collapsed. Slides that continue one another are not exempt; give a continuation a distinct title such as \"(continued)\".\n\n### `opf/reading-order`\n\nDefault severity: **warning**. Cost: composition. The order content is read differs from the order it appears on the slide.\n\n**Why.** Screen readers, keyboard focus and PowerPoint's selection pane follow the composed order. When it differs from the visual order (top to bottom, then start to end of the reading direction), the slide is read out of sequence.\n\n**Basis.** WCAG 2.2 SC 1.3.2 Meaningful Sequence, level A (PowerPoint: \"Check reading order\")\n\n**Approximations.** Compares the composed content order with a visual order recomputed from the composed boxes: items whose vertical centres fall in the same row are ordered along the reading direction (a right-to-left deck is checked by rows only), rows from top to bottom. Headings are expected first. Free-form overlap is not analysed.\n\n### `opf/link-text`\n\nDefault severity: **warning**. Cost: structure. Link text does not say where the link goes.\n\n**Why.** People who scan links out of context (a screen reader's links list, a link tab order) hear only the text. \"click here\", \"read more\" or a long raw URL tells them nothing about the destination.\n\n**Basis.** WCAG 2.2 SC 2.4.4 Link Purpose (In Context), level A; PowerPoint: \"Hyperlink text is not meaningful\"\n\n**Approximations.** Matches a short list of generic phrases in English (after lower-casing and removing punctuation), blank link text, and raw URLs longer than 40 characters. Adjacent runs sharing one link are read as one link.\n\n### `opf/chart-color-only`\n\nDefault severity: **info**. Cost: structure. Chart series may be indistinguishable without colour vision.\n\n**Why.** OPF charts have no data labels or patterns, so series are told apart by colour alone (and legend order). Colours that look alike to someone with colour-vision deficiency, or when printed in greyscale, make series impossible to tell apart.\n\n**Basis.** WCAG 2.2 SC 1.4.1 Use of Color, level A\n\n**Thresholds.** `minSeriesColorDifference` (default 10)\n\n**Approximations.** Uses the engine's series palette (ValidateOptions.chartPalette; default the opf-render/opf-pptx palette, adjusted for the card surface like the preview does) in series order: series i takes colour i, pie/doughnut/treemap/funnel slices take colours per category. Pairs are compared by CIE76 distance after simulating protanopia, deuteranopia, tritanopia (Machado 2009, severity 1) and greyscale. Single-series charts and chart types without a series legend are skipped.\n\n### `opf/chart-text-alternative`\n\nDefault severity: **info**. Cost: structure. A chart has no text alternative, or is marked decorative.\n\n**Why.** A chart conveys a message; people who cannot see it need the message and ideally the numbers in text. The chart's alt field is that text alternative (the preview exposes it as the chart's accessible name and the PowerPoint export writes it as the frame's alternative text); a sentence or table beside the chart also serves. An empty alt marks a chart decorative, which is reported as info so the choice is reviewed: a chart rarely carries no message.\n\n**Basis.** WCAG 2.2 SC 1.1.1 Non-text Content, level A\n\n**Approximations.** A chart passes when chart.alt has text. Without alt, it passes when the slide has any other text, list, table, quote or metric content besides title and tag, or a subtitle. It does not judge whether alt or that text states the chart's point (see opf/poor-alt-text for generic alt text). alt: \"\" is reported as a decorative chart, whatever else is on the slide.\n\n### `opf/missing-language`\n\nDefault severity: **info**. Cost: structure. The presentation does not declare its language.\n\n**Why.** Screen readers and text-to-speech choose pronunciation and hyphenation from the declared language; spell checkers and translation tools use it too.\n\n**Basis.** WCAG 2.2 SC 3.1.1 Language of Page, level A\n\n**Approximations.** Only the presentation-level `language` is checked, not the language of individual runs (OPF has no per-run language).\n\n\n## Layout rules\n\n*Will it present as authored?*\n\n### `opf/chart-option-adapted`\n\nDefault severity: **warning**. Cost: structure. A chart option cannot be shown by the chart type.\n\n**Why.** An axis title, legend or data label option the chart type does not support is adapted by every engine, so the chart is not drawn exactly as authored.\n\n**Basis.** spec/schemas/opf.schema.json (JSON Schema 2020-12) and the semantic rules of OPF\n\n### `opf/text-overflow`\n\nDefault severity: **warning**. Cost: composition. Text or a table does not fit its space at the smallest allowed size.\n\n**Why.** Core composition shrinks text to the readable minimum and then reports what still does not fit. Overflowing text is clipped or runs over other content in the preview and in PowerPoint.\n\n**Approximations.** Uses composeSlide at the deck's slide size with the fonts of the resolved font scheme. Without a host-supplied text measurement (ValidateOptions.fonts.textMeasurement) widths are core's portable estimate, which can differ from the real font by a few percent; pass the renderer's measurement for font-exact results. A composition that sets overflow: \"error\" is reported as a warning here, so that `valid` never depends on font metrics; rendering and export still refuse such a slide when it does not fit.\n\n### `opf/unresolved-content`\n\nDefault severity: **warning**. Cost: composition. Content cannot be drawn as authored.\n\n**Why.** composeSlide reports content it cannot place or an effect it does not support (for example a picture bullet without a logo, a date field without a date, or an unsupported image treatment). The preview and the export fall back.\n\n### `opf/layout-failed`\n\nDefault severity: **warning**. Cost: composition. The slide layout could not be computed.\n\n**Why.** Composition threw for a slide that passed schema validation, so geometry-based rules (contrast, overflow, reading order, resolution) were skipped for it.\n\n### `opf/min-font-size`\n\nDefault severity: **warning**. Cost: composition. Text is drawn smaller than the readable minimum.\n\n**Why.** Small type is unreadable from the back of a room and on a phone. The engine's own default floor is 12 pt (16 px); anything much below that was lowered on purpose or by a composition that could not fit the text.\n\n**Basis.** Common presentation guidance (12 pt minimum for body text; 18 pt or more is easier to read from a distance).\n\n**Thresholds.** `minFontSizePt` (default 11)\n\n**Approximations.** Sizes are those composeSlide fitted, expressed on the 13.33 x 7.5 in reference slide (96 px per inch, so 1 px is 0.75 pt; a smaller canvas scales type down with it) and explicit run fontSize values in points. Per payload, the smallest part (a metric label or a quote attribution) is reported. Table cell text (default 11.25 pt), code and header/footer furniture are not checked.\n\n### `opf/font-outside-scheme`\n\nDefault severity: **warning**. Cost: structure. Text uses a font family that is not in the deck's font scheme.\n\n**Why.** The font scheme is the deck's font choice. A run that names another family will not follow a font-scheme change, may be missing on the viewer's machine, and breaks the deck's typographic consistency.\n\n**Approximations.** Compares the run's fontFamily (case-insensitively) with the heading, body, code and accent families of the slide's resolved font scheme and the scheme's major/minor fonts. Families that the host substitutes are still different names here.\n\n### `opf/image-resolution`\n\nDefault severity: **warning**. Cost: composition. An image has too few pixels for the size it is shown at.\n\n**Why.** An image stretched beyond its pixel size looks blurry or blocky on a projector or a high-density screen.\n\n**Thresholds.** `minImagePpi` (default 96)\n\n**Approximations.** Only embedded data: images (and asset: references to them) have readable pixel sizes; URLs and files are never fetched, so they are not checked. The displayed size is the composed box (cropped images are measured as the cover scale, fitted ones as the contain scale). Effective ppi is the image pixels per inch of the 96 px/inch reference slide. SVG is vector and exempt.\n\n\n## Content rules\n\n*Is anything left unfinished?*\n\n### `opf/chart-value-not-numeric`\n\nDefault severity: **warning**. Cost: structure. A chart value is text, not a number.\n\n**Why.** A chart plots numbers. A text cell such as \"12%\" is plotted as a gap. When every text value of a column is written in one display style the finding carries the fix that stores the numbers and gives the column the matching format.\n\n**Basis.** spec/schemas/opf.schema.json (JSON Schema 2020-12) and the semantic rules of OPF\n\n### `opf/chart-mapping-adapted`\n\nDefault severity: **warning**. Cost: structure. A chart mapping entry is ignored.\n\n**Why.** A mapping that names a column the chart type cannot use is left out of the chart.\n\n**Basis.** spec/schemas/opf.schema.json (JSON Schema 2020-12) and the semantic rules of OPF\n\n### `opf/chart-highlight-adapted`\n\nDefault severity: **warning**. Cost: structure. A chart highlight is adapted by the chart type.\n\n**Why.** A highlight the chart type cannot show as authored (a category highlight on a chart without categories, for example) is adapted by every engine, so the emphasis is not exactly what was written.\n\n**Basis.** spec/schemas/opf.schema.json (JSON Schema 2020-12) and the semantic rules of OPF\n\n### `opf/code-highlight-out-of-range`\n\nDefault severity: **warning**. Cost: structure. A code highlight marks a line the code does not have.\n\n**Why.** Marked lines count from 1 by line break in code.source; an entry past the last line marks nothing.\n\n**Basis.** spec/schemas/opf.schema.json (JSON Schema 2020-12) and the semantic rules of OPF\n\n### `opf/code-highlight-range-reversed`\n\nDefault severity: **warning**. Cost: structure. A code highlight range ends before it starts.\n\n**Why.** A range written end before start marks nothing; write the smaller line number first.\n\n**Basis.** spec/schemas/opf.schema.json (JSON Schema 2020-12) and the semantic rules of OPF\n\n### `opf/variable-builtin-missing`\n\nDefault severity: **warning**. Cost: structure. A built-in variable has no value in this document.\n\n**Why.** A `{{deck.*}}`, `{{speaker.*}}` or `{{organization.*}}` token whose field the document does not set resolves to nothing, so the text shows a gap.\n\n**Basis.** spec/schemas/opf.schema.json (JSON Schema 2020-12) and the semantic rules of OPF\n\n### `opf/duration-outside-narrative`\n\nDefault severity: **warning**. Cost: structure. The target duration is outside the range the narrative suits.\n\n**Why.** A narrative records the talk lengths it suits. A deck whose duration is outside that range probably needs a different narrative or a different length; nothing is drawn or exported differently.\n\n**Basis.** spec/schemas/opf.schema.json (JSON Schema 2020-12) and the semantic rules of OPF\n\n### `opf/narrative-duration-range`\n\nDefault severity: **warning**. Cost: structure. An inline narrative duration range has min greater than max.\n\n**Why.** A range with its bounds swapped matches no duration, so the duration check cannot help.\n\n**Basis.** spec/schemas/narrative.schema.json\n\n### `opf/placeholder-text`\n\nDefault severity: **warning**. Cost: structure. Placeholder text was left in the deck.\n\n**Why.** \"Lorem ipsum\", \"Click to add title\", \"TBD\" and bracketed prompts are scaffolding. Shipping them reads as unfinished work.\n\n**Approximations.** Case-insensitive patterns for lorem ipsum, template prompts (\"Click to add...\", \"Your title here\", \"[Insert ...]\", a bare \"Title\" or \"Text\"), and the markers TBD, TBC, TODO, FIXME and XXX in capitals. A deliberate use of the words is flagged too; disable the rule for that slide with ignorePaths.\n\n### `opf/empty-text`\n\nDefault severity: **info**. Cost: structure. A text field is present but empty.\n\n**Why.** An empty title, text block or list item draws nothing, and leaves a hole in the outline and for assistive technology.\n\n### `opf/unused-beat`\n\nDefault severity: **info**. Cost: structure. A beat of the narrative has no slide that references it.\n\n**Why.** The narrative is the plan and the slides are the product. When some slides name a beat (slides[].beat) and a beat of the plan has none, the deck skips a step of the story or the plan is out of date.\n\n**Approximations.** Resolved offline like every catalog reference: the inline catalogs.narratives.records of the document, then records passed in the `catalogs` option, then the bundled catalog. A narrative given as a URL or a pkg: reference, or an id no local source defines, is not checked. A deck in which no slide references any beat is not checked either, because it has not linked its slides to the plan. A slide that lists several beats covers each of them.\n\n### `opf/empty-slide`\n\nDefault severity: **warning**. Cost: structure. A slide has no content at all.\n\n**Why.** A slide with no title, no content and no picture is almost always an accident of editing. (A deliberately blank slide can use the blank layout.)\n\n<!-- validate-rules:end -->\n"
325
+ "markdown": "# OPF validate: one checker for format, references, accessibility, layout and content\n\n`validate` in `@openpresentation/opf` and `opf validate` in the CLI check an OPF presentation for everything a tool can tell without an opinion: whether it is well-formed OPF, whether everything it points at resolves, whether a host's house rules hold, and the problems a schema cannot see, such as text that is hard to read, text that does not fit, pictures without alt text, content read out of order, fonts outside the deck's scheme, placeholder text and slides left empty. It is the only checker. Releases before 0.14 had several (a schema validator, a lint and an audit, in the library and the CLI); 0.14 replaces them all with `validate`.\n\n`validate` is read-only, local and deterministic. It fetches nothing (no images, fonts or catalogs), consults no clock and calls no model. The same document, options and fonts give byte-identical findings.\n\n```sh\nopf validate deck.opf.json # everything, JSON report\nopf validate deck.opf.json --format text # one line per finding\nopf validate deck.opf.json --only format,references --fail-on warning\nopf validate deck.opf.json --ignore layout --config house-rules.json\nopf validate --list-rules\n```\n\n```js\nimport { validate } from '@openpresentation/opf';\n\nconst report = validate(presentation); // a parsed presentation\nconst fromText = validate(fileText); // strict JSON text: also syntax, duplicate keys and line/column\nvalidate(presentation, { only: ['format'] }); // the cheap check: schema and semantic rules only\nvalidate(presentation, { ignore: ['layout'] }); // everything except the checks that build slide layouts\nvalidate(presentation, { contracts, severity: { 'opf/text-contrast': 'error' } }); // house rules\n```\n\n## Categories\n\nEvery finding has one of six categories. They say what kind of question the finding answers, and they select what runs.\n\n| Category | Asks | Errors by default | Examples |\n| --- | --- | --- | --- |\n| `format` | Is it well-formed OPF? | yes | JSON syntax, duplicate keys, schema, a citation of an unknown reference, an unknown dataset, a required variable with no value (a warning in a template), a `name:id` prefix that names no catalog group |\n| `references` | Does everything it points at resolve? | missing or circular asset, invalid catalog record; a reference that resolves nowhere and an unused dataset are warnings | `asset:` references, catalog references, citations and datasets nobody uses |\n| `policy` | Does it follow the host's house rules? | the host sets it (default `error`) | \"only these layouts\", brand fonts: the `contracts` option |\n| `accessibility` | Can everyone read it? | no: warning and info | contrast, alt text, slide titles, reading order, link text, language |\n| `layout` | Will it present as authored? | no: warning | overflow, minimum type size, image resolution, fonts outside the scheme |\n| `content` | Is anything left unfinished? | no: warning and info | placeholder text, an empty slide, a chart value that is text |\n\n`valid` keeps meaning \"correct OPF\": it is true when no finding has severity `error`, and by default only `format`, `references` and `policy` can produce one. A low-contrast deck is still a valid deck. A team promotes the rules it cares about with `severity`.\n\nEvery rule is listed, with its category, default severity, cost and the basis it rests on, in [Rules](#rules) below. A rule stays in core only when it has a measurable basis (a standard such as WCAG 2.2, a print-preflight check, or a number computed from the layout); judgments that need an audience and a purpose, such as how many fonts or words a deck should have, are not rules here.\n\n## Input and report\n\n`validate(input, options?)` takes a parsed presentation (an object) or **strict JSON text**. A string is read as JSON and nothing else: it is never sniffed for YAML or Markdown. When the text begins like YAML (`key: value`, `---`) or Markdown (`#`), the `help` of the syntax finding says to read it with `fromYaml` or `fromMarkdown` first, which return a presentation and call `validate` for you.\n\nWith text, a syntax error or a duplicate key comes back with `location` (original-source UTF-16 offset and length, one-based line and column), and so does every finding about a value in the document; a finding about a missing field is located at the object that lacks it. Text that is not JSON reports its syntax errors (`opf/json-syntax`) whatever `only` says, because nothing else can run.\n\nThe report is a [`FindingReport`](finding-schema-reference.md) (`spec/schemas/finding.schema.json`) with a few more fields:\n\n| Field | Meaning |\n| --- | --- |\n| `valid` | No finding has severity `error` |\n| `findings` | Every finding, ordered by slide (document-level findings first), then category, then rule |\n| `counts` | `{ error, warning, info }` |\n| `schemaValid` | The document passes the schema and the semantic format checks; `null` for text that is not JSON. A required variable with no value does not make a document schema-invalid |\n| `checks` | What ran and what was not measured (below) |\n| `template`, `unfilledVariables` | Documents that declare content variables or are templates: whether they were checked as a template, and which required variables have no value |\n\nA finding has a stable `ruleId` (`opf/text-contrast`), a `severity` (`error`, `warning`, `info`), a `category`, a JSON Pointer `path`, a `message`, a `help` sentence and a `definition` link to the rule below or to the schema constraint. It may carry `slide` and `slideId` (zero-based index and id of the slide it belongs to), `location` (from text), `measured` (the numbers behind it: ratios, sizes, counts), `suggestions` (catalog records that exist, never silently applied), `lookup` (arguments for `opf schema` or `opf catalog`, not a shell string), `validation` (the raw schema issue, with every union alternative) and `fixes`.\n\n`checks` says what each part did: `syntax` (`checked` for text), `schema`, `references`, `policy`, `accessibility` and `content` are `checked` or `not-run`; `layout` is `not-run`, `estimated` (text widths are core's portable estimate) or `measured` (the host passed `fonts.textMeasurement`); background picture pixels are never read (`backgroundPixels`), image resolution reads only embedded `data:` images (`imageBytes`), and native PowerPoint rendering is not checked (`nativeExport`).\n\nA document the schema rejects is not audited: its accessibility, layout and content rules do not run, and its format errors are reported even when `only` did not ask for them, so the report says why nothing else ran (switch them off with `ignore: ['format']`).\n\n## Options\n\n```js\nvalidate(presentation, {\n only: ['format', 'opf/missing-alt-text'], // run just these rules or categories\n ignore: ['layout'], // do not run these\n severity: { 'opf/text-contrast': 'error', content: 'off' }, // per rule or category: error, warning, info or off\n ignorePaths: [{ rule: 'opf/text-contrast', path: '/slides/3' }], // an accepted exception, by JSON Pointer prefix\n catalogs: [defaultCatalog, acmeCatalog], // the catalogs the host registered (the first is the default); nothing is fetched\n contracts, // host policy (below)\n thresholds: { contrastNormal: 7 }, // see DEFAULT_VALIDATION_THRESHOLDS\n fonts, // the host's fonts: layout rules read fonts.textMeasurement\n chartPalette: ['#0072B2', '#E69F00'], // the engine's chart colours\n template: true, values: { client: 'Acme' }, // check as a template, or fill variables first\n});\n```\n\n- **Names.** `only`, `ignore`, `severity` and `ignorePaths` take full rule ids (`opf/text-contrast`), bare rule names (`text-contrast`) or category names (`accessibility`). No rule is named like a category. A rule beats its category in `severity`. An unknown rule, category, threshold or option throws a `TypeError`, so a typo cannot silently disable a check.\n- **`fonts`.** An object with a `textMeasurement` (the renderer's `loadFonts()` result has one). Without it the layout rules use core's portable estimate and `checks.layout` says `estimated`; with it, `measured`. `textMeasurement` may also be a function of the zero-based slide index, for a script-aware measurement per slide.\n- **`template` and `values`.** A deck that declares content variables is checked as the deck it resolves to; see [templates and variables](templates-and-variables.md).\n- **Thresholds.** `DEFAULT_VALIDATION_THRESHOLDS` holds `contrastNormal`, `contrastLarge`, `minFontSizePt`, `minImagePpi` and `minSeriesColorDifference`; each rule below lists the ones it reads.\n\n`validationRules` lists every rule with its metadata (`id`, `name`, `category`, `severity`, `cost`, `summary`, `rationale`, `standard`, `approximations`, `thresholds`), and `findValidationRule(name)` finds one by id or bare name.\n\n`assertValid(value, options?)` throws `OPFValidationError`, carrying the `report` and its error `findings`, unless `validate(value, options)` is valid, and tells TypeScript that `value` is a `Presentation`. It takes a parsed value, not text.\n\n`validateCatalogRecord(kind, record)` and `assertValidCatalogRecord(kind, record)` check one catalog record (an audience, theme, layout, font scheme and so on) against its schema. The report has the same shape; its findings are `format`, and a record that links to an unknown id only warns.\n\n## Cost\n\n`validate` is lazy: it builds only what the rules you ask for need. Measured with Node 24 on three bundled example decks (milliseconds per call, warm; your machine will differ, the ratios hold):\n\n| Call | `minimal` (3 slides) | `renewal-save-plan` (6 slides, 34 KB) | `broadband-public-hearing-deck` (10 slides, 41 KB) |\n| --- | --- | --- | --- |\n| `only: ['format']` | 0.06 | 0.23 | 0.64 |\n| `only: ['format', 'references']` | 0.7 | 5.2 | 7.6 |\n| everything (the default) | 1.7 | 24 | 32 |\n\n`only: ['format']` costs what the schema check alone costs; it builds no layout. The references walk reads the schema alongside the document and costs about ten times as much. The rules of `layout` (and `opf/text-contrast`, `opf/text-on-image` and `opf/reading-order`) compose every slide and are most of the cost of a full run. Each rule's `cost` says which it needs: `syntax` reads the JSON text, `structure` reads the document alone, `composition` builds slide layouts.\n\nThe default runs everything, so an agent or CI job never gets \"valid\" for a deck that overflows, and the hot paths say what they need: pagination, conversion, patch validation and the editor's per-edit check use `{ only: ['format'] }`; the CLI's write, render, export and import commands use `{ only: ['format', 'references'] }`, so a contrast warning never blocks an export.\n\n## Quick fixes\n\nA fix is a suggestion that core never applies. A host such as the editor's Review panel shows it and applies it through its own undoable edit path.\n\n- A `patch` fix (`kind: \"patch\"`, the default) carries RFC 6902 JSON Patch operations in `patch` (`add`, `remove`, `replace`, `move`, `copy`, `test`, with JSON Pointer paths). `safe: true` means the change cannot alter what the content says: for example switching a failing text colour to the colour the theme chose for that background.\n- A `focus` fix (`kind: \"focus\"`) names the field the author must fill in (`focus.path`, `focus.field`: `alt`, `title`, `text`, `link`, `language`, `fontSize`).\n- `safe: false` patches change meaning or appearance and need a deliberate click, for example marking a picture decorative with an empty `alt`.\n\n`title` says what applying the fix does.\n\n## Catalog context and contracts\n\nContent references resolve the way every engine resolves them ([catalogs](default-catalog.md)): `catalogs.custom`, then the records embedded under `catalogs.default`, then the catalog the host registered (the `catalogs` option) for its source; `name:id` in `catalogs.<name>`, then the catalog registered for its source. A reference that resolves nowhere is a warning (`opf/unresolved-reference`), never an error; a prefix that names no group is a format error (`opf/undeclared-catalog`): it can never resolve, so engines reject the document at their format check (`validate(document, { only: [\"format\"] })`) instead of drawing a fallback. A `catalogs` option that is not an array of registered catalogs throws `OPFCatalogsOptionError` (`code: \"invalid-catalogs\"`) at every entry point. Nothing is fetched, and without the `catalogs` option only the records the document embeds resolve. Free-form audience and purpose descriptions (any string that is not a bare id or `name:id`) and arbitrary `extensions` data are never references; the `language` tag, `chart.type` and the `socials` keys are engine vocabularies the schema checks. Embedded records, and registered records the document uses, are validated against their companion schema (`opf/catalog-record`). A record under `catalogs.default` or a named group that the catalog registered for the group's source does not publish is `opf/catalog-record-not-in-source` (move it to `catalogs.custom`); with no catalog registered for that source the check is silent. Suggestions name records actually present in the checked context (`origin` `document` or `registered`).\n\nAn `asset:` reference that names no entry of the document's `assets` registry is an error (`opf/asset-reference`), and so is a cycle of `asset:` sources (`opf/asset-cycle`); resource bytes are never fetched. Chart and table data findings keep their code as the rule id (`opf/dataset-unknown`, `opf/chart-value-not-numeric`, ...), and a `opf/chart-value-not-numeric` cell whose column is written in one display style (`\"12%\"`, `\"$1,234\"`) carries `fixes` ([chart and table data](chart-table-data.md#migration-help)). Citation and caption errors keep theirs too (`opf/cite-unknown-reference`, `opf/reference-id-duplicate`, `opf/cite-unsupported-location`, `opf/caption-unsupported-payload`).\n\nA narrative the document defines is a record in `catalogs.custom.narratives`, and `narrative` is always a reference string. When the narrative resolves, a `slides[].beat` id it does not define is `opf/unknown-beat`, a root `duration` outside the record's `duration` range is `opf/duration-outside-narrative`, an embedded record whose `duration.min` exceeds `duration.max` is `opf/narrative-duration-range`, and a beat no slide references is `opf/unused-beat` (only once some slide names a beat). A slide-level `design.theme` whose resolved dimensions differ from the deck's is `opf/slide-theme-dimensions`: a PPTX has one slide size, and a slide's `design` cannot set `dimensions` at all (a schema error). An undeclared `var:<id>` in a solid, gradient-stop or pattern background colour warns like any other colour reference (`opf/variable-reference-unknown`).\n\nHost policy comes from explicit options, never from the document: fields under `extensions` are data and cannot install policy. `contracts` and `catalogs` may live in a local JSON file for the CLI (`--config`).\n\n```json\n{\n \"catalogs\": {\n \"layouts\": [\n {\"id\":\"partner-title\",\"name\":\"Partner title\",\"placeholders\":[{\"type\":\"title\"}]}\n ]\n },\n \"contracts\": [\n {\n \"path\":\"/slides/*/layout\",\n \"allowedValues\":[\"partner-title\",\"text-1x\"],\n \"message\":\"Use the brand layouts {{allowed}} at {{path}}. See {{file}}.\",\n \"documentation\":\"brand-guide.md#layouts\",\n \"severity\":\"error\"\n }\n ]\n}\n```\n\nContract paths are JSON Pointer patterns: `~0` escapes `~`, `~1` escapes `/`, and a whole `*` segment matches one property or array index. Contracts check existing fields; they do not require an omitted field or insert defaults. Allowed values are JSON primitives. Optional message placeholders are `{{path}}`, `{{value}}`, `{{allowed}}`, and `{{file}}`. A contract's severity is `error` unless it says otherwise. Invalid or misspelled configuration keys fail instead of being ignored. Messages and catalog labels are data, not executable instructions.\n\n## The CLI\n\n`opf validate <file|-> [--config <file>] [--only <list>] [--ignore <list>] [--fail-on <error|warning|info>] [--format <json|text>] [--list-rules]`\n\nThe JSON report (the default) is the `validate` report plus the source's SHA-256 (`sha256`), the bundled core version (`opfVersion`) and, with `--config`, the configuration file's path and hash (`context`). `--format text` prints one line per finding (`file:line:column severity rule message`), the JSON Pointer path, a hint and any fix, then a summary. `--list-rules` prints every rule (`--format json` with its full metadata).\n\nA file ending `.yaml` or `.yml` (or stdin with `--input-format yaml`) is read as YAML through `fromYaml`, so the report is the same and every finding is located in the YAML; a YAML syntax or dialect error is a `yaml/<rule>` finding (`format`, exit 1).\n\n`--only` and `--ignore` take comma-separated rule ids, bare names or category names. `--config` takes an explicit local JSON file, never `-`, holding `{ catalogs, contracts, severity, only, ignore, ignorePaths, thresholds, chartPalette }`; `--only` replaces the file's `only` and `--ignore` adds to its `ignore`.\n\n| Exit | Meaning |\n| --- | --- |\n| 0 | No finding at or above `--fail-on` (default `error`) |\n| 1 | At least one finding at or above `--fail-on`, or a document that is not valid JSON (or YAML) |\n| 2 | Usage, configuration or I/O error |\n\n`--fail-on` is the one way to say \"fail on warnings\" on every command that checks a document: `--fail-on warning`. Other commands (`create`, `edit`, `fill`, `paginate`, `bundle`, `import-data`, `render`, `export`, `import`, `from-md`, `from-yaml`) check only `format` and `references` of what they write and take `--fail-on` the same way.\n\n## How contrast is computed\n\n`opf/text-contrast` reasons about the colours the preview draws, because a check against a different colour than the one on screen is worse than none.\n\n1. **Background.** Design resolves per field: slide design, deck design, theme, engine default. The background is a theme slot or colour (`solid`), the card surface for content on a `contentBox` card, a table cell's fill, a gradient or a pattern. Solid, gradient-stop and pattern colours are read as literal hex colours, as the preview reads them (anything else falls back as it does there). Translucent backgrounds are composited over white.\n2. **Text colour.** Titles, body, lists and tags use the scheme's `dark1`, or `light1` when the background is dark (luminance below 0.179). Furniture and quote attributions use the muted colour, metric values the primary colour, and explicit run and table colours the author's `ColorRef` resolved the same way the renderer resolves it. As the preview does, a gradient background has no single colour and counts as light, so a dark gradient behind default text is reported.\n3. **Gradients.** The gradient is sampled on a 5 by 5 grid over the area the text covers (the lines' measured ink, aligned as the text is), using the preview's gradient geometry, and the worst colour decides. A pattern contributes both of its colours.\n4. **Pictures.** Pixels are never read. A background picture is bounded by a grey ramp from black to white, composited through the picture opacity and any full-frame overlay of the image background (`design.background.overlay`). Text passes only if every step passes; otherwise `opf/text-on-image` says the result cannot be guaranteed. An overlay limited to an edge band is not counted.\n5. **Ratio and size.** The WCAG 2.x relative-luminance contrast ratio is compared with 4.5:1, or 3:1 for large text: at least 18 pt, or 14 pt and bold. Sizes are the fitted sizes, expressed at the 13.33 by 7.5 in reference slide (96 px per inch). The default body size, 18.75 pt, is large text under WCAG, so default body text passes at 3:1; set `contrastLarge` to 4.5 for a stricter policy.\n\nApproximations: anti-aliasing, text shadows, font weight and the exact glyph coverage are not modelled; chart and code text are not checked (charts pick label colours against their surface; code uses its own panel colours). See each rule below for what it cannot see.\n\n## Layout rules use composition\n\nOverflow, minimum type size, reading order and image resolution use `composeSlide` at the deck's slide size with the resolved font scheme, the same composition the preview and the PPTX export consume. Without `fonts.textMeasurement` the text widths are core's portable estimate and can differ from the real fonts by a few percent; hosts that load fonts (the editor, the renderer) pass their measurement for font-exact results. A composition with `overflow: \"error\"` fails at composition time; `validate` still computes the geometry, relaxes the setting, and reports the overflow as a **warning**, so that `valid` never depends on font metrics. Rendering and export still refuse such a slide when it does not fit, and a host that wants the finding to fail promotes it with `severity: { 'opf/text-overflow': 'error' }`.\n\nPromoted region keys (`left`, `center`, `right`, `top:left` and the rest) are composed in visual reading order (rows from top to bottom, then along the row), whatever order the keys are written in. The PPTX export writes shapes in that order, which is the reading order of assistive technology; `opf/reading-order` checks that the composed order still matches the geometry (the same `visualReadingOrder` that composes the regions). Authoring with `blocks` (an ordered list) keeps the reading order equal to the visual order.\n\n## Templates and variables\n\n`opf/variable-unfilled` is one rule for a required variable that has no value. In a deck that is not a template it is an error (the deck is incomplete), so `valid` is false; in a template (`\"template\": true` in the document, or `template: true` in the options) it is a warning, because a template is incomplete on purpose; `unfilledVariables` lists the ids either way. In a document that declares no content variables, a `{{id}}` token left in a string (outside `variables`, `extensions`, `catalogs` and `assets`) is a warning. A `var:` colour that names no declared variable is `opf/variable-reference-unknown`. It is a `format` rule, so `only: ['format']` (what pagination, patching, conversion and every write command run) rejects a deck with an unfilled required variable. Fill a template with `opf fill` or `resolveVariables`, or pass `values`, to clear the findings.\n\n## Rules\n\nThe reference below is generated from the rule registry (`validationRules`); `node scripts/build-validate-docs.mjs --check` (and a core test) fails when it drifts.\n\n<!-- validate-rules:start (generated by scripts/build-validate-docs.mjs; do not edit by hand) -->\n\n| Rule | Category | Default | Cost | What it reports |\n| --- | --- | --- | --- | --- |\n| [`opf/json-syntax`](#opfjson-syntax) | Format | error | syntax | The text is not strict JSON. |\n| [`opf/duplicate-key`](#opfduplicate-key) | Format | error | syntax | A JSON object repeats a key. |\n| [`opf/schema`](#opfschema) | Format | error | structure | The document breaks the OPF schema or a structural rule the schema cannot express. |\n| [`opf/reference-id-duplicate`](#opfreference-id-duplicate) | Format | error | structure | Two references share an id. |\n| [`opf/cite-unknown-reference`](#opfcite-unknown-reference) | Format | error | structure | A citation names a reference that does not exist. |\n| [`opf/cite-unsupported-location`](#opfcite-unsupported-location) | Format | error | structure | A citation or footnote sits where no engine draws it. |\n| [`opf/caption-unsupported-payload`](#opfcaption-unsupported-payload) | Format | error | structure | A caption is on a payload that cannot carry one. |\n| [`opf/image-option-unsupported-payload`](#opfimage-option-unsupported-payload) | Format | error | structure | An image option is on a payload that is not an image. |\n| [`opf/image-placement-invalid`](#opfimage-placement-invalid) | Format | error | structure | An image placement cannot be honoured. |\n| [`opf/dataset-unknown`](#opfdataset-unknown) | Format | error | structure | A chart or table names a dataset that does not exist. |\n| [`opf/dataset-field-unknown`](#opfdataset-field-unknown) | Format | error | structure | A table asks a dataset for a column it does not have. |\n| [`opf/data-column-duplicate`](#opfdata-column-duplicate) | Format | error | structure | A dataset or table has two columns with the same name. |\n| [`opf/chart-mapping-unknown-column`](#opfchart-mapping-unknown-column) | Format | error | structure | A chart mapping names a column the data does not have. |\n| [`opf/number-format-invalid`](#opfnumber-format-invalid) | Format | error | structure | A number format cannot be read. |\n| [`opf/variable-invalid-value`](#opfvariable-invalid-value) | Format | error | structure | A variable value does not match its declared type. |\n| [`opf/variable-unknown-builtin`](#opfvariable-unknown-builtin) | Format | error | structure | A token names a built-in variable that does not exist. |\n| [`opf/chart-highlight-unknown-name`](#opfchart-highlight-unknown-name) | Format | error | structure | A chart highlight names a series or category the chart does not have. |\n| [`opf/variable-format`](#opfvariable-format) | Format | error | structure | A variable cannot be formatted. |\n| [`opf/variable-unfilled`](#opfvariable-unfilled) | Format | error | structure | A required template variable has no value. |\n| [`opf/run-color-unrecognized`](#opfrun-color-unrecognized) | Format | warning | structure | A text run colour is none of the documented forms. |\n| [`opf/numbering-start-ignored`](#opfnumbering-start-ignored) | Format | warning | structure | A list entry sets `start` where nothing is numbered. |\n| [`opf/undeclared-catalog`](#opfundeclared-catalog) | Format | error | structure | A reference names a catalog group the document does not declare. |\n| [`opf/asset-reference`](#opfasset-reference) | References | error | structure | An `asset:` reference names an asset that is not in the registry. |\n| [`opf/asset-cycle`](#opfasset-cycle) | References | error | structure | Asset references form a cycle. |\n| [`opf/catalog-record`](#opfcatalog-record) | References | error | structure | An embedded or registered catalog record is invalid. |\n| [`opf/unresolved-reference`](#opfunresolved-reference) | References | warning | structure | A content reference resolves nowhere. |\n| [`opf/catalog-record-not-in-source`](#opfcatalog-record-not-in-source) | References | warning | structure | A record embedded under a catalog group is not in that catalog. |\n| [`opf/unused-reference`](#opfunused-reference) | References | warning | structure | A reference is never cited. |\n| [`opf/unused-dataset`](#opfunused-dataset) | References | warning | structure | A dataset is never used. |\n| [`opf/unknown-beat`](#opfunknown-beat) | References | warning | structure | A slide names a beat its narrative does not define. |\n| [`opf/slide-theme-dimensions`](#opfslide-theme-dimensions) | References | warning | structure | A slide theme sets a slide size that differs from the deck. |\n| [`opf/variable-unknown`](#opfvariable-unknown) | References | warning | structure | A `{{token}}` names a variable that is not declared. |\n| [`opf/variable-unknown-value`](#opfvariable-unknown-value) | References | warning | structure | A value was supplied for a variable that is not declared. |\n| [`opf/variable-unused`](#opfvariable-unused) | References | warning | structure | A variable is declared but never used. |\n| [`opf/variable-reference-unknown`](#opfvariable-reference-unknown) | References | warning | structure | A `var:` colour names a variable that is not declared. |\n| [`opf/contract`](#opfcontract) | Policy | error | structure | A field is outside the values a host policy allows. |\n| [`opf/text-contrast`](#opftext-contrast) | Accessibility | warning | composition | Text colour has too little contrast against the background it sits on. |\n| [`opf/text-on-image`](#opftext-on-image) | Accessibility | info | composition | Text sits on a background picture whose pixels cannot be measured. |\n| [`opf/missing-alt-text`](#opfmissing-alt-text) | Accessibility | warning | structure | A picture has no alt text and is not marked decorative. |\n| [`opf/poor-alt-text`](#opfpoor-alt-text) | Accessibility | info | structure | Alt text is a file name, a URL, a generic word or very long. |\n| [`opf/missing-slide-title`](#opfmissing-slide-title) | Accessibility | warning | structure | A slide has no title. |\n| [`opf/duplicate-slide-title`](#opfduplicate-slide-title) | Accessibility | info | structure | Two slides have the same title. |\n| [`opf/reading-order`](#opfreading-order) | Accessibility | warning | composition | The order content is read differs from the order it appears on the slide. |\n| [`opf/link-text`](#opflink-text) | Accessibility | warning | structure | Link text does not say where the link goes. |\n| [`opf/chart-color-only`](#opfchart-color-only) | Accessibility | info | structure | Chart series may be indistinguishable without colour vision. |\n| [`opf/chart-text-alternative`](#opfchart-text-alternative) | Accessibility | info | structure | A chart has no text alternative, or is marked decorative. |\n| [`opf/missing-language`](#opfmissing-language) | Accessibility | info | structure | The presentation does not declare its language. |\n| [`opf/chart-option-adapted`](#opfchart-option-adapted) | Layout | warning | structure | A chart option cannot be shown by the chart type. |\n| [`opf/text-overflow`](#opftext-overflow) | Layout | warning | composition | Text or a table does not fit its space at the smallest allowed size. |\n| [`opf/unresolved-content`](#opfunresolved-content) | Layout | warning | composition | Content cannot be drawn as authored. |\n| [`opf/layout-failed`](#opflayout-failed) | Layout | warning | composition | The slide layout could not be computed. |\n| [`opf/min-font-size`](#opfmin-font-size) | Layout | warning | composition | Text is drawn smaller than the readable minimum. |\n| [`opf/font-outside-scheme`](#opffont-outside-scheme) | Layout | warning | structure | Text uses a font family that is not in the deck's font scheme. |\n| [`opf/image-resolution`](#opfimage-resolution) | Layout | warning | composition | An image has too few pixels for the size it is shown at. |\n| [`opf/chart-value-not-numeric`](#opfchart-value-not-numeric) | Content | warning | structure | A chart value is text, not a number. |\n| [`opf/chart-mapping-adapted`](#opfchart-mapping-adapted) | Content | warning | structure | A chart mapping entry is ignored. |\n| [`opf/chart-highlight-adapted`](#opfchart-highlight-adapted) | Content | warning | structure | A chart highlight is adapted by the chart type. |\n| [`opf/code-highlight-out-of-range`](#opfcode-highlight-out-of-range) | Content | warning | structure | A code highlight marks a line the code does not have. |\n| [`opf/code-highlight-range-reversed`](#opfcode-highlight-range-reversed) | Content | warning | structure | A code highlight range ends before it starts. |\n| [`opf/variable-builtin-missing`](#opfvariable-builtin-missing) | Content | warning | structure | A built-in variable has no value in this document. |\n| [`opf/duration-outside-narrative`](#opfduration-outside-narrative) | Content | warning | structure | The target duration is outside the range the narrative suits. |\n| [`opf/narrative-duration-range`](#opfnarrative-duration-range) | Content | warning | structure | An inline narrative duration range has min greater than max. |\n| [`opf/placeholder-text`](#opfplaceholder-text) | Content | warning | structure | Placeholder text was left in the deck. |\n| [`opf/empty-text`](#opfempty-text) | Content | info | structure | A text field is present but empty. |\n| [`opf/unused-beat`](#opfunused-beat) | Content | info | structure | A beat of the narrative has no slide that references it. |\n| [`opf/empty-slide`](#opfempty-slide) | Content | warning | structure | A slide has no content at all. |\n\n## Format rules\n\n*Is it well-formed OPF?*\n\n### `opf/json-syntax`\n\nDefault severity: **error**. Cost: syntax. The text is not strict JSON.\n\n**Why.** Every OPF tool reads JSON. A misplaced comma, quote, comment or trailing comma makes the file unreadable, and the range says where.\n\n**Basis.** RFC 8259 (strict JSON: no comments, no trailing commas)\n\n**Approximations.** Only text input is checked. A string is always read as JSON; a text that looks like YAML or Markdown gets a hint to use fromYaml or fromMarkdown first.\n\n### `opf/duplicate-key`\n\nDefault severity: **error**. Cost: syntax. A JSON object repeats a key.\n\n**Why.** JSON.parse keeps only the last value of a repeated key, so the earlier one disappears without a trace. The check compares escaped and literal spellings of the same key.\n\n**Basis.** RFC 8259 section 4 (names within an object should be unique)\n\n### `opf/schema`\n\nDefault severity: **error**. Cost: structure. The document breaks the OPF schema or a structural rule the schema cannot express.\n\n**Why.** The schema is the contract every engine, exporter and editor relies on. A field of the wrong type, a missing required field, an unknown property or content fields that cannot be mixed make the document ambiguous or undrawable.\n\n**Basis.** spec/schemas/opf.schema.json (JSON Schema 2020-12) and the semantic rules of OPF\n\n**Approximations.** All alternatives of a union are reported, with the failing schema location in `validation`. A required variable with no value is reported by `opf/variable-unfilled`, in the same category.\n\n### `opf/reference-id-duplicate`\n\nDefault severity: **error**. Cost: structure. Two references share an id.\n\n**Why.** A citation names a reference by id; a repeated id makes it ambiguous which entry is cited.\n\n**Basis.** spec/schemas/opf.schema.json (JSON Schema 2020-12) and the semantic rules of OPF\n\n### `opf/cite-unknown-reference`\n\nDefault severity: **error**. Cost: structure. A citation names a reference that does not exist.\n\n**Why.** A cited id with no entry in `references` has nothing to show in the footnote area.\n\n**Basis.** spec/schemas/opf.schema.json (JSON Schema 2020-12) and the semantic rules of OPF\n\n### `opf/cite-unsupported-location`\n\nDefault severity: **error**. Cost: structure. A citation or footnote sits where no engine draws it.\n\n**Why.** `cite` and `footnote` apply to runs in text, bullets and list items; table cells, captions, and reference and footnote texts have no marker.\n\n**Basis.** spec/schemas/opf.schema.json (JSON Schema 2020-12) and the semantic rules of OPF\n\n### `opf/caption-unsupported-payload`\n\nDefault severity: **error**. Cost: structure. A caption is on a payload that cannot carry one.\n\n**Why.** A caption belongs to exactly one image, chart, table or video; a group, text or code block has no place to draw it.\n\n**Basis.** spec/schemas/opf.schema.json (JSON Schema 2020-12) and the semantic rules of OPF\n\n### `opf/image-option-unsupported-payload`\n\nDefault severity: **error**. Cost: structure. An image option is on a payload that is not an image.\n\n**Why.** `fit`, `focus`, the image treatments (`shape`, `cornerRadius`, `border`, `opacity`, `recolor`, `overlay`, `aspectRatio`) and `placement` describe how a picture is drawn; a group, text or chart block has no picture for them to apply to.\n\n**Basis.** spec/schemas/opf.schema.json (JSON Schema 2020-12) and the semantic rules of OPF\n\n### `opf/image-placement-invalid`\n\nDefault severity: **error**. Cost: structure. An image placement cannot be honoured.\n\n**Why.** A placed image bleeds to a slide edge and the rest of the slide composes beside it. Only a top-level block (slides.N.blocks.I) can do that, and a slide edge holds one placed image; a block in a group or a promoted region, or a second block on the same edge, has nowhere to go.\n\n**Basis.** spec/schemas/opf.schema.json (JSON Schema 2020-12) and the semantic rules of OPF\n\n### `opf/dataset-unknown`\n\nDefault severity: **error**. Cost: structure. A chart or table names a dataset that does not exist.\n\n**Why.** The data cannot be drawn without the dataset it refers to.\n\n**Basis.** spec/schemas/opf.schema.json (JSON Schema 2020-12) and the semantic rules of OPF\n\n### `opf/dataset-field-unknown`\n\nDefault severity: **error**. Cost: structure. A table asks a dataset for a column it does not have.\n\n**Why.** The column cannot be shown when the dataset has no column of that name.\n\n**Basis.** spec/schemas/opf.schema.json (JSON Schema 2020-12) and the semantic rules of OPF\n\n### `opf/data-column-duplicate`\n\nDefault severity: **error**. Cost: structure. A dataset or table has two columns with the same name.\n\n**Why.** Series, mappings and fields address columns by name, so a repeated name makes them ambiguous.\n\n**Basis.** spec/schemas/opf.schema.json (JSON Schema 2020-12) and the semantic rules of OPF\n\n### `opf/chart-mapping-unknown-column`\n\nDefault severity: **error**. Cost: structure. A chart mapping names a column the data does not have.\n\n**Why.** The series cannot be built from a column that is not there.\n\n**Basis.** spec/schemas/opf.schema.json (JSON Schema 2020-12) and the semantic rules of OPF\n\n### `opf/number-format-invalid`\n\nDefault severity: **error**. Cost: structure. A number format cannot be read.\n\n**Why.** A format code that no engine can parse is drawn as the plain number or fails to export.\n\n**Basis.** Excel number format codes, as far as OPF supports them\n\n### `opf/variable-invalid-value`\n\nDefault severity: **error**. Cost: structure. A variable value does not match its declared type.\n\n**Why.** A number variable given text, or a date given a non-date, cannot be filled into the deck.\n\n**Basis.** spec/schemas/opf.schema.json (JSON Schema 2020-12) and the semantic rules of OPF\n\n### `opf/variable-unknown-builtin`\n\nDefault severity: **error**. Cost: structure. A token names a built-in variable that does not exist.\n\n**Why.** The `deck.`, `speaker.` and `organization.` names (and `speakers`) are reserved for built-in variables; a name under them that is not a built-in can never be filled, so the token would show literally.\n\n**Basis.** spec/schemas/opf.schema.json (JSON Schema 2020-12) and the semantic rules of OPF\n\n### `opf/chart-highlight-unknown-name`\n\nDefault severity: **error**. Cost: structure. A chart highlight names a series or category the chart does not have.\n\n**Why.** `chart.highlight` emphasises marks by name; a name that matches no series or category in the chart data highlights nothing, so the chart is not drawn as authored.\n\n**Basis.** spec/schemas/opf.schema.json (JSON Schema 2020-12) and the semantic rules of OPF\n\n### `opf/variable-format`\n\nDefault severity: **error**. Cost: structure. A variable cannot be formatted.\n\n**Why.** A number or date format that does not apply to the value leaves the token unreadable.\n\n**Basis.** spec/schemas/opf.schema.json (JSON Schema 2020-12) and the semantic rules of OPF\n\n### `opf/variable-unfilled`\n\nDefault severity: **error**. Cost: structure. A required template variable has no value.\n\n**Why.** A variable a deck declares as required, with no value, is template scaffolding: `{{name}}` tokens show up literally and colour variables fall back to a default. A normal deck must be filled, so this is an error and the deck is not valid; a template (`template: true`) is incomplete on purpose, so it is a warning.\n\n**Approximations.** Required variables are those the variable machinery reports as unfilled for the document (declared, no value, not `required: false`, not given by the `values` option). For a document that declares no content variables, `{{id}}` / `{{id|format}}` tokens (`{{` escapes) left in any string outside `variables`, `extensions`, `catalogs` and `assets` are reported as warnings. `var:` colour references with no declaration are reported as `opf/variable-reference-unknown`. Fill a template with `opf fill` or `resolveVariables` to clear the findings.\n\n### `opf/run-color-unrecognized`\n\nDefault severity: **warning**. Cost: structure. A text run colour is none of the documented forms.\n\n**Why.** Run colours are open strings so imported decks stay valid, but a value that is not a hex colour, a colour-scheme name or a `var:` reference falls back to the theme colour.\n\n**Basis.** spec/schemas/opf.schema.json (JSON Schema 2020-12) and the semantic rules of OPF\n\n### `opf/numbering-start-ignored`\n\nDefault severity: **warning**. Cost: structure. A list entry sets `start` where nothing is numbered.\n\n**Why.** A start value only restarts an auto-number; without `numbering` on the payload it has no effect.\n\n**Basis.** spec/schemas/opf.schema.json (JSON Schema 2020-12) and the semantic rules of OPF\n\n### `opf/undeclared-catalog`\n\nDefault severity: **error**. Cost: structure. A reference names a catalog group the document does not declare.\n\n**Why.** The prefix of a `name:id` reference names the group of `catalogs` it resolves in. A prefix with no group can never resolve, whatever the host registers, so it is a format error: engines reject the document at their format check instead of drawing a fallback.\n\n**Basis.** spec/schemas/opf.schema.json (JSON Schema 2020-12) and the semantic rules of OPF\n\n\n## References rules\n\n*Does everything it points at resolve?*\n\n### `opf/asset-reference`\n\nDefault severity: **error**. Cost: structure. An `asset:` reference names an asset that is not in the registry.\n\n**Why.** An image, logo or video that points at a missing asset cannot be drawn or exported. The check reads the document's own registry only; no file or URL is fetched.\n\n**Basis.** spec/schemas/opf.schema.json (JSON Schema 2020-12) and the semantic rules of OPF\n\n### `opf/asset-cycle`\n\nDefault severity: **error**. Cost: structure. Asset references form a cycle.\n\n**Why.** An asset whose source points back at itself never resolves to a file.\n\n**Basis.** spec/schemas/opf.schema.json (JSON Schema 2020-12) and the semantic rules of OPF\n\n### `opf/catalog-record`\n\nDefault severity: **error**. Cost: structure. An embedded or registered catalog record is invalid.\n\n**Why.** A record that fails its companion schema cannot be resolved. Invalid records are left out of the lookup, so the references to them are reported too.\n\n**Basis.** spec/schemas/<kind>.schema.json\n\n### `opf/unresolved-reference`\n\nDefault severity: **warning**. Cost: structure. A content reference resolves nowhere.\n\n**Why.** A layout, theme, colour scheme, font scheme, narrative, audience, purpose or tone reference that neither the document embeds nor a registered catalog defines falls back: a slide composes automatically, a design uses the engine default. A strict export fails instead. A slide with no layout is automatic composition and is never reported.\n\n**Basis.** spec/schemas/opf.schema.json (JSON Schema 2020-12) and the semantic rules of OPF\n\n**Approximations.** Resolved like every engine resolves it: catalogs.custom, then the records embedded under catalogs.default, then the catalog registered for its source (the first registered catalog when `default` is omitted); `name:id` in catalogs.<name>, then the catalog registered for its source. Nothing is fetched. Free-form audience and purpose text is not a reference.\n\n### `opf/catalog-record-not-in-source`\n\nDefault severity: **warning**. Cost: structure. A record embedded under a catalog group is not in that catalog.\n\n**Why.** A record under catalogs.default or a named group says it came from the catalog its source names. When the catalog the host registered for that source has no record of that kind and id, the record is the document's own and belongs in catalogs.custom, where an update from the catalog never looks for it. Rendering is unchanged: the embedded record still wins.\n\n**Basis.** spec/schemas/opf.schema.json (JSON Schema 2020-12) and the semantic rules of OPF\n\n**Approximations.** Checked only against a catalog the host registered for the group's source (the first registered catalog for an omitted default.source); with none registered for it the check is silent. A record the catalog has with different content is an update difference (updateFromCatalog), not this finding. catalogs.custom is never checked.\n\n### `opf/unused-reference`\n\nDefault severity: **warning**. Cost: structure. A reference is never cited.\n\n**Why.** A listed reference that no run cites is not drawn anywhere.\n\n**Basis.** spec/schemas/opf.schema.json (JSON Schema 2020-12) and the semantic rules of OPF\n\n### `opf/unused-dataset`\n\nDefault severity: **warning**. Cost: structure. A dataset is never used.\n\n**Why.** A dataset no chart or table references is not drawn anywhere.\n\n**Basis.** spec/schemas/opf.schema.json (JSON Schema 2020-12) and the semantic rules of OPF\n\n### `opf/unknown-beat`\n\nDefault severity: **warning**. Cost: structure. A slide names a beat its narrative does not define.\n\n**Why.** `slides[].beat` links a slide to a step of the narrative plan. A beat id the narrative does not have links to nothing; nothing is drawn from a beat.\n\n**Basis.** spec/schemas/opf.schema.json (JSON Schema 2020-12) and the semantic rules of OPF\n\n**Approximations.** The narrative resolves like every content reference: the document's catalogs groups, then the registered catalogs. A narrative that resolves nowhere is not checked.\n\n### `opf/slide-theme-dimensions`\n\nDefault severity: **warning**. Cost: structure. A slide theme sets a slide size that differs from the deck.\n\n**Why.** A PPTX has one slide size. The size a slide-level theme carries is never used: design.dimensions of the deck (or of its theme) wins in every engine, and without them the exporter stops with mixed slide sizes.\n\n**Basis.** spec/schemas/opf.schema.json (JSON Schema 2020-12) and the semantic rules of OPF\n\n### `opf/variable-unknown`\n\nDefault severity: **warning**. Cost: structure. A `{{token}}` names a variable that is not declared.\n\n**Why.** An undeclared token is shown literally.\n\n**Basis.** spec/schemas/opf.schema.json (JSON Schema 2020-12) and the semantic rules of OPF\n\n### `opf/variable-unknown-value`\n\nDefault severity: **warning**. Cost: structure. A value was supplied for a variable that is not declared.\n\n**Why.** The value is never used.\n\n**Basis.** spec/schemas/opf.schema.json (JSON Schema 2020-12) and the semantic rules of OPF\n\n### `opf/variable-unused`\n\nDefault severity: **warning**. Cost: structure. A variable is declared but never used.\n\n**Why.** A declaration nothing refers to has no effect.\n\n**Basis.** spec/schemas/opf.schema.json (JSON Schema 2020-12) and the semantic rules of OPF\n\n### `opf/variable-reference-unknown`\n\nDefault severity: **warning**. Cost: structure. A `var:` colour names a variable that is not declared.\n\n**Why.** An undeclared colour variable falls back to a default colour.\n\n**Basis.** spec/schemas/opf.schema.json (JSON Schema 2020-12) and the semantic rules of OPF\n\n\n## Policy rules\n\n*Does it follow the host's house rules?*\n\n### `opf/contract`\n\nDefault severity: **error**. Cost: structure. A field is outside the values a host policy allows.\n\n**Why.** A host that needs brand layouts, fonts or other fixed choices states them as contracts. The default severity is error; a contract can set its own. Document metadata never installs policy.\n\n**Approximations.** Only the `contracts` option supplies policy, and it checks existing fields: a contract does not require an omitted field or insert a default.\n\n\n## Accessibility rules\n\n*Can everyone read it?*\n\n### `opf/text-contrast`\n\nDefault severity: **warning**. Cost: composition. Text colour has too little contrast against the background it sits on.\n\n**Why.** Low-contrast text is hard or impossible to read for people with low vision, colour-vision differences, glare on a projector or a poor display. WCAG sets 4.5:1 for normal text and 3:1 for large text.\n\n**Basis.** WCAG 2.2 SC 1.4.3 Contrast (Minimum), level AA\n\n**Thresholds.** `contrastNormal` (default 4.5), `contrastLarge` (default 3)\n\n**Approximations.** Computed on sRGB colours with the WCAG relative-luminance formula, against the background the preview draws: a solid or theme colour, the card surface, a table cell fill, every colour a gradient takes under the text box (sampled on a 5x5 grid, angle respected), or both colours of a pattern. Anti-aliasing, text shadows and font weight are not modelled. Text colour is the preview's (the scheme's text role or dark1 on a light background and light1 on a dark one, chosen from the background luminance, where a gradient or picture background uses the scheme's default background colour), so a default can fail on a dark gradient. A link with no colour of its own is measured in the scheme's hyperlink colour.\n\n### `opf/text-on-image`\n\nDefault severity: **info**. Cost: composition. Text sits on a background picture whose pixels cannot be measured.\n\n**Why.** Contrast over a photograph depends on the photograph. Without a full-frame overlay that guarantees readability for every possible image, the result cannot be certified from the document.\n\n**Basis.** WCAG 2.2 SC 1.4.3 Contrast (Minimum), level AA\n\n**Thresholds.** `contrastNormal` (default 4.5), `contrastLarge` (default 3)\n\n**Approximations.** Core never reads picture pixels. The picture is bounded by a grey ramp from black to white, composited through the image opacity and a full-frame design.background overlay; the text passes only when every step of that ramp passes. An edge-banded overlay is not counted.\n\n### `opf/missing-alt-text`\n\nDefault severity: **warning**. Cost: structure. A picture has no alt text and is not marked decorative.\n\n**Why.** People using a screen reader get nothing for a picture without alternative text. A picture that is purely decorative should say so with an empty alt (alt: \"\"), which is an explicit, reviewed choice instead of an omission.\n\n**Basis.** WCAG 2.2 SC 1.1.1 Non-text Content, level A\n\n**Approximations.** Checks the alt field of images (image blocks, Slide.image and region images, placed or not), video, logos (design.logo and each LogoSet variant, organization.logo), header/footer images, quote photos and speaker photos, following asset: references to the assets registry. Whether the text describes the picture well is not judged here (see opf/poor-alt-text). Charts carry `chart.alt` and are checked by opf/chart-text-alternative. Picture backgrounds are decorative unless they carry their own alt, and watermarks are decorative, so neither is checked.\n\n### `opf/poor-alt-text`\n\nDefault severity: **info**. Cost: structure. Alt text is a file name, a URL, a generic word or very long.\n\n**Why.** Alt text such as \"image\", \"IMG_2041.png\" or a 400-character paragraph does not do the job of describing a picture: it is read out and adds noise without information.\n\n**Basis.** WCAG 2.2 SC 1.1.1 Non-text Content, level A\n\n**Approximations.** Pattern checks only: file extensions and camera-style names, a bare generic word, a URL, a leading \"image of\", and more than 250 characters. Chart alt text (chart.alt) is checked too: a bare chart word, a URL, a leading \"chart of\" or more than 250 characters. It cannot tell whether a plausible sentence is accurate.\n\n### `opf/missing-slide-title`\n\nDefault severity: **warning**. Cost: structure. A slide has no title.\n\n**Why.** Slide titles are how people using a screen reader, an outline view or keyboard navigation find and tell slides apart; PowerPoint's own accessibility checker reports a missing title too.\n\n**Basis.** WCAG 2.2 SC 2.4.2 Page Titled and SC 2.4.6 Headings and Labels, level AA (PowerPoint: \"Missing slide title\")\n\n**Approximations.** Only the slide-level title field counts. Text that merely looks like a heading inside a block does not.\n\n### `opf/duplicate-slide-title`\n\nDefault severity: **info**. Cost: structure. Two slides have the same title.\n\n**Why.** Identical titles make slides indistinguishable in an outline or a screen reader's slide list.\n\n**Approximations.** Titles are compared case-insensitively with whitespace collapsed. Slides that continue one another are not exempt; give a continuation a distinct title such as \"(continued)\".\n\n### `opf/reading-order`\n\nDefault severity: **warning**. Cost: composition. The order content is read differs from the order it appears on the slide.\n\n**Why.** Screen readers, keyboard focus and PowerPoint's selection pane follow the composed order. When it differs from the visual order (top to bottom, then start to end of the reading direction), the slide is read out of sequence.\n\n**Basis.** WCAG 2.2 SC 1.3.2 Meaningful Sequence, level A (PowerPoint: \"Check reading order\")\n\n**Approximations.** Compares the composed content order with a visual order recomputed from the composed boxes: items whose vertical centres fall in the same row are ordered along the reading direction (a right-to-left deck is checked by rows only), rows from top to bottom. Headings are expected first. Free-form overlap is not analysed.\n\n### `opf/link-text`\n\nDefault severity: **warning**. Cost: structure. Link text does not say where the link goes.\n\n**Why.** People who scan links out of context (a screen reader's links list, a link tab order) hear only the text. \"click here\", \"read more\" or a long raw URL tells them nothing about the destination.\n\n**Basis.** WCAG 2.2 SC 2.4.4 Link Purpose (In Context), level A; PowerPoint: \"Hyperlink text is not meaningful\"\n\n**Approximations.** Matches a short list of generic phrases in English (after lower-casing and removing punctuation), blank link text, and raw URLs longer than 40 characters. Adjacent runs sharing one link are read as one link.\n\n### `opf/chart-color-only`\n\nDefault severity: **info**. Cost: structure. Chart series may be indistinguishable without colour vision.\n\n**Why.** OPF charts have no data labels or patterns, so series are told apart by colour alone (and legend order). Colours that look alike to someone with colour-vision deficiency, or when printed in greyscale, make series impossible to tell apart.\n\n**Basis.** WCAG 2.2 SC 1.4.1 Use of Color, level A\n\n**Thresholds.** `minSeriesColorDifference` (default 10)\n\n**Approximations.** Uses the engine's series palette (ValidateOptions.chartPalette; default the opf-render/opf-pptx palette, adjusted for the card surface like the preview does) in series order: series i takes colour i, pie/doughnut/treemap/funnel slices take colours per category. Pairs are compared by CIE76 distance after simulating protanopia, deuteranopia, tritanopia (Machado 2009, severity 1) and greyscale. Single-series charts and chart types without a series legend are skipped.\n\n### `opf/chart-text-alternative`\n\nDefault severity: **info**. Cost: structure. A chart has no text alternative, or is marked decorative.\n\n**Why.** A chart conveys a message; people who cannot see it need the message and ideally the numbers in text. The chart's alt field is that text alternative (the preview exposes it as the chart's accessible name and the PowerPoint export writes it as the frame's alternative text); a sentence or table beside the chart also serves. An empty alt marks a chart decorative, which is reported as info so the choice is reviewed: a chart rarely carries no message.\n\n**Basis.** WCAG 2.2 SC 1.1.1 Non-text Content, level A\n\n**Approximations.** A chart passes when chart.alt has text. Without alt, it passes when the slide has any other text, list, table, quote or metric content besides title and tag, or a subtitle. It does not judge whether alt or that text states the chart's point (see opf/poor-alt-text for generic alt text). alt: \"\" is reported as a decorative chart, whatever else is on the slide.\n\n### `opf/missing-language`\n\nDefault severity: **info**. Cost: structure. The presentation does not declare its language.\n\n**Why.** Screen readers and text-to-speech choose pronunciation and hyphenation from the declared language; spell checkers and translation tools use it too.\n\n**Basis.** WCAG 2.2 SC 3.1.1 Language of Page, level A\n\n**Approximations.** Only the presentation-level `language` is checked, not the language of individual runs (OPF has no per-run language).\n\n\n## Layout rules\n\n*Will it present as authored?*\n\n### `opf/chart-option-adapted`\n\nDefault severity: **warning**. Cost: structure. A chart option cannot be shown by the chart type.\n\n**Why.** An axis title, legend or data label option the chart type does not support is adapted by every engine, so the chart is not drawn exactly as authored.\n\n**Basis.** spec/schemas/opf.schema.json (JSON Schema 2020-12) and the semantic rules of OPF\n\n### `opf/text-overflow`\n\nDefault severity: **warning**. Cost: composition. Text or a table does not fit its space at the smallest allowed size.\n\n**Why.** Core composition shrinks text to the readable minimum and then reports what still does not fit. Overflowing text is clipped or runs over other content in the preview and in PowerPoint.\n\n**Approximations.** Uses composeSlide at the deck's slide size with the fonts of the resolved font scheme. Without a host-supplied text measurement (ValidateOptions.fonts.textMeasurement) widths are core's portable estimate, which can differ from the real font by a few percent; pass the renderer's measurement for font-exact results. A composition that sets overflow: \"error\" is reported as a warning here, so that `valid` never depends on font metrics; rendering and export still refuse such a slide when it does not fit.\n\n### `opf/unresolved-content`\n\nDefault severity: **warning**. Cost: composition. Content cannot be drawn as authored.\n\n**Why.** composeSlide reports content it cannot place or an effect it does not support (for example a picture bullet without a logo, a date field without a date, or an unsupported image treatment). The preview and the export fall back.\n\n### `opf/layout-failed`\n\nDefault severity: **warning**. Cost: composition. The slide layout could not be computed.\n\n**Why.** Composition threw for a slide that passed schema validation, so geometry-based rules (contrast, overflow, reading order, resolution) were skipped for it.\n\n### `opf/min-font-size`\n\nDefault severity: **warning**. Cost: composition. Text is drawn smaller than the readable minimum.\n\n**Why.** Small type is unreadable from the back of a room and on a phone. The engine's own default floor is 12 pt (16 px); anything much below that was lowered on purpose or by a composition that could not fit the text.\n\n**Basis.** Common presentation guidance (12 pt minimum for body text; 18 pt or more is easier to read from a distance).\n\n**Thresholds.** `minFontSizePt` (default 11)\n\n**Approximations.** Sizes are those composeSlide fitted, expressed on the 13.33 x 7.5 in reference slide (96 px per inch, so 1 px is 0.75 pt; a smaller canvas scales type down with it) and explicit run fontSize values in points. Per payload, the smallest part (a metric label or a quote attribution) is reported. Table cell text (default 11.25 pt), code and header/footer furniture are not checked.\n\n### `opf/font-outside-scheme`\n\nDefault severity: **warning**. Cost: structure. Text uses a font family that is not in the deck's font scheme.\n\n**Why.** The font scheme is the deck's font choice. A run that names another family will not follow a font-scheme change, may be missing on the viewer's machine, and breaks the deck's typographic consistency.\n\n**Approximations.** Compares the run's fontFamily (case-insensitively) with the heading, body, code and accent families of the slide's resolved font scheme and the scheme's major/minor fonts. Families that the host substitutes are still different names here.\n\n### `opf/image-resolution`\n\nDefault severity: **warning**. Cost: composition. An image has too few pixels for the size it is shown at.\n\n**Why.** An image stretched beyond its pixel size looks blurry or blocky on a projector or a high-density screen.\n\n**Thresholds.** `minImagePpi` (default 96)\n\n**Approximations.** Only embedded data: images (and asset: references to them) have readable pixel sizes; URLs and files are never fetched, so they are not checked. The displayed size is the composed frame (cover and stretch are measured as the cover scale, contain as the contain scale). Effective ppi is the image pixels per inch of the 96 px/inch reference slide. SVG is vector and exempt.\n\n\n## Content rules\n\n*Is anything left unfinished?*\n\n### `opf/chart-value-not-numeric`\n\nDefault severity: **warning**. Cost: structure. A chart value is text, not a number.\n\n**Why.** A chart plots numbers. A text cell such as \"12%\" is plotted as a gap. When every text value of a column is written in one display style the finding carries the fix that stores the numbers and gives the column the matching format.\n\n**Basis.** spec/schemas/opf.schema.json (JSON Schema 2020-12) and the semantic rules of OPF\n\n### `opf/chart-mapping-adapted`\n\nDefault severity: **warning**. Cost: structure. A chart mapping entry is ignored.\n\n**Why.** A mapping that names a column the chart type cannot use is left out of the chart.\n\n**Basis.** spec/schemas/opf.schema.json (JSON Schema 2020-12) and the semantic rules of OPF\n\n### `opf/chart-highlight-adapted`\n\nDefault severity: **warning**. Cost: structure. A chart highlight is adapted by the chart type.\n\n**Why.** A highlight the chart type cannot show as authored (a category highlight on a chart without categories, for example) is adapted by every engine, so the emphasis is not exactly what was written.\n\n**Basis.** spec/schemas/opf.schema.json (JSON Schema 2020-12) and the semantic rules of OPF\n\n### `opf/code-highlight-out-of-range`\n\nDefault severity: **warning**. Cost: structure. A code highlight marks a line the code does not have.\n\n**Why.** Marked lines count from 1 by line break in code.source; an entry past the last line marks nothing.\n\n**Basis.** spec/schemas/opf.schema.json (JSON Schema 2020-12) and the semantic rules of OPF\n\n### `opf/code-highlight-range-reversed`\n\nDefault severity: **warning**. Cost: structure. A code highlight range ends before it starts.\n\n**Why.** A range written end before start marks nothing; write the smaller line number first.\n\n**Basis.** spec/schemas/opf.schema.json (JSON Schema 2020-12) and the semantic rules of OPF\n\n### `opf/variable-builtin-missing`\n\nDefault severity: **warning**. Cost: structure. A built-in variable has no value in this document.\n\n**Why.** A `{{deck.*}}`, `{{speaker.*}}` or `{{organization.*}}` token whose field the document does not set resolves to nothing, so the text shows a gap.\n\n**Basis.** spec/schemas/opf.schema.json (JSON Schema 2020-12) and the semantic rules of OPF\n\n### `opf/duration-outside-narrative`\n\nDefault severity: **warning**. Cost: structure. The target duration is outside the range the narrative suits.\n\n**Why.** A narrative records the talk lengths it suits. A deck whose duration is outside that range probably needs a different narrative or a different length; nothing is drawn or exported differently.\n\n**Basis.** spec/schemas/opf.schema.json (JSON Schema 2020-12) and the semantic rules of OPF\n\n### `opf/narrative-duration-range`\n\nDefault severity: **warning**. Cost: structure. An inline narrative duration range has min greater than max.\n\n**Why.** A range with its bounds swapped matches no duration, so the duration check cannot help.\n\n**Basis.** spec/schemas/narrative.schema.json\n\n### `opf/placeholder-text`\n\nDefault severity: **warning**. Cost: structure. Placeholder text was left in the deck.\n\n**Why.** \"Lorem ipsum\", \"Click to add title\", \"TBD\" and bracketed prompts are scaffolding. Shipping them reads as unfinished work.\n\n**Approximations.** Case-insensitive patterns for lorem ipsum, template prompts (\"Click to add...\", \"Your title here\", \"[Insert ...]\", a bare \"Title\" or \"Text\"), and the markers TBD, TBC, TODO, FIXME and XXX in capitals. A deliberate use of the words is flagged too; disable the rule for that slide with ignorePaths.\n\n### `opf/empty-text`\n\nDefault severity: **info**. Cost: structure. A text field is present but empty.\n\n**Why.** An empty title, text block or list item draws nothing, and leaves a hole in the outline and for assistive technology.\n\n### `opf/unused-beat`\n\nDefault severity: **info**. Cost: structure. A beat of the narrative has no slide that references it.\n\n**Why.** The narrative is the plan and the slides are the product. When some slides name a beat (slides[].beat) and a beat of the plan has none, the deck skips a step of the story or the plan is out of date.\n\n**Approximations.** Resolved like every content reference: the document's catalogs groups, then the registered catalogs. A narrative that resolves nowhere is not checked. A deck in which no slide references any beat is not checked either, because it has not linked its slides to the plan. A slide that lists several beats covers each of them.\n\n### `opf/empty-slide`\n\nDefault severity: **warning**. Cost: structure. A slide has no content at all.\n\n**Why.** A slide with no title, no content and no picture is almost always an accident of editing. (A deliberately blank slide can use the blank layout.)\n\n<!-- validate-rules:end -->\n"
326
326
  },
327
327
  {
328
328
  "slug": "yaml",
329
329
  "file": "docs/yaml.md",
330
330
  "title": "OPF as YAML",
331
- "markdown": '# OPF as YAML\n\n`@openpresentation/opf/yaml` (RR-56) reads and writes an OPF deck as YAML, in both directions, with no renderer, fonts, network or model: the same input always gives the same output. The CLI exposes it as `opf from-yaml` and `opf to-yaml`, and every command that reads or writes a deck understands `.opf.yaml` files.\n\n- **JSON stays canonical.** The schema, the interchange form and every package describe the JSON. A `.opf.yaml` file is an authoring serialization of the same data: the parsed YAML is exactly the JSON document, and the [schema](../spec/schemas/opf.schema.json) validates that parsed data.\n- **Write a deck by hand.** YAML has no braces, no quotes around keys and no trailing-comma errors, and a block scalar (`text: |`) keeps a paragraph readable. Comments are allowed in the file (they are not data).\n- **One strict dialect.** JSON-compatible YAML 1.2, core schema. Anything that could mean something else in another reader, or cannot exist in JSON, is an error with a line and a column.\n- **Round trip.** `toYaml` writes canonical text that `fromYaml` reads back to the same deck, and that converts back to itself byte for byte. Every one of the 127 example decks round-trips, JSON to YAML to JSON and YAML to JSON to YAML.\n- **Errors have places.** Findings use the shared Finding format of [validate](validate.md) and carry the source offset, length, line and column of the YAML that caused them, including the OPF findings mapped back to the YAML of the field they name.\n\n```js\nimport { fromYaml, toYaml } from "@openpresentation/opf/yaml";\n\nconst { presentation, valid, findings } = fromYaml(text);\nconst { yaml } = toYaml(presentation, { schemaComment: true });\n```\n\n```sh\nopf from-yaml deck.opf.yaml deck.opf.json\nopf to-yaml deck.opf.json deck.opf.yaml --schema-comment\nopf validate deck.opf.yaml\nopf render deck.opf.yaml --out slides\n```\n\n## An example\n\n```yaml\n# yaml-language-server: $schema=https://openpresentation.org/schema/opf/v1\n$schema: https://openpresentation.org/schema/opf/v1\nname: Q3 Business Review\nlanguage: en-US\nslides:\n - id: cover\n title: Q3 Business Review\n subtitle: Operations and growth\n notes: Two minutes on the agenda, then straight into the numbers.\n - title: Revenue grew every quarter\n blocks:\n - chart:\n type: column\n data:\n columns:\n - Quarter\n - Revenue\n rows:\n - - Q1\n - 12\n - - Q2\n - 18\n - quote:\n text: We would rather spend a week on capacity than a month on an outage.\n attribution: Priya Raman, Head of Platform\n```\n\nis the same deck as the JSON it converts to: `name`, `language` and `slides` with the values above. A comment and the first line are not data.\n\n## The dialect\n\n`fromYaml` accepts exactly this, and nothing else:\n\n| Rule | What happens otherwise |\n| --- | --- |\n| One document, with a mapping at the root | `yaml/empty` (nothing, or only comments), `yaml/multiple-documents` (a `---` separates two), `yaml/not-mapping` (a list or a bare value) |\n| YAML 1.2 core schema | `2026-10-01`, `yes`, `no`, `on`, `off` and `1_000` stay **strings**: only `true`/`false`, `null`/`~` and numbers (decimal, `0x1F`, `0o17`, exponents) are typed. Quote a value to force text |\n| No duplicate keys | `yaml/duplicate-key`, at the repeated key |\n| Keys are strings | `yaml/key` for `1:`, `true:`, `null:` or a collection used as a key. Quote it: `"1":` |\n| No custom tags | `yaml/tag` for `!custom`, `!!binary`, `!!set` and the like. The core tags `!!str`, `!!int`, `!!float`, `!!bool`, `!!null`, `!!map` and `!!seq` are allowed |\n| Finite numbers only | `yaml/number` for `.inf`, `-.inf` and `.nan`, with the hint to quote them |\n| No anchors, aliases or merge keys | `yaml/alias`, with help that points at `aliases: true` |\n| Syntax | `yaml/syntax` for anything the parser rejects, at the position it reports |\n\nA leading byte order mark and CRLF line endings are accepted. Offsets are UTF-16 units into the text you passed (a BOM counts as one); lines and columns are one-based and ignore the BOM. After the YAML parses, the deck goes through the OPF lint and each finding (`opf/...` rule ids) is located at the YAML node of the field it names: the value for a scalar, the key through the value for a mapping or a list, and the nearest existing ancestor for something that is missing.\n\n### `aliases: true`\n\nAnchors (`&name`), aliases (`*name`) and the `<<` merge key are valid YAML and handy for repeated values, but they are not JSON, many tools handle them differently, and an alias hides the size of what it expands to. They are refused by default. `fromYaml(text, { aliases: true })` (CLI: `opf from-yaml --aliases`) expands them to plain data:\n\n```yaml\nslides:\n - title: One\n extensions:\n owner: &owner { team: ops, oncall: true }\n - title: Two\n extensions:\n owner: *owner\n other:\n <<: *owner\n extra: 1\n```\n\nThe expansion is capped by the `yaml` package\'s `maxAliasCount` (100; each alias counts the nodes it expands to, so nested aliases that multiply exponentially are refused with `yaml/alias-limit`), and an alias that sits inside its own anchor is `yaml/alias-cycle`. What comes out is always finite, tree-shaped JSON data, and keys, tags and numbers are checked exactly as without the option. Writing never produces anchors or aliases.\n\n## Writing OPF as YAML\n\n`toYaml(presentation, { schemaComment })` first validates the presentation (`OPFYamlError`, `code: "invalid-document"`, when it is not valid OPF), then writes the canonical form:\n\n- keys in the order of the schema (the same order as [`opf format`](../packages/cli/README.md)): `$schema`, `name`, ..., `slides`, `assets`, ..., with keys the schema does not declare after them in their own order;\n- two-space indentation, block style throughout, no line folding (`lineWidth: 0`), a block scalar (`|`) for text with line breaks, one trailing newline, LF line endings;\n- strings that would read as another type, or as another type in a YAML 1.1 reader, are quoted: `"yes"`, `"No"`, `"on"`, `"2026-10-01"`, `"123"`, `"null"`, `"0x1F"`, `"12:30"`, and any string with leading or trailing spaces, a `#` or `: `. Mapping keys such as `"yes":` follow the same rule;\n- the text is **read back before it is returned**: it is parsed again in the strict dialect and compared with the input, key order included. If it does not read back to the same data, `toYaml` throws `OPFYamlError` with `code: "not-representable"` instead of writing something wrong (for example a lone surrogate in a string).\n\n`schemaComment: true` starts the file with `# yaml-language-server: $schema=<url>`, the deck\'s own `$schema` or else the canonical `https://openpresentation.org/schema/opf/v1`.\n\n### Editor autocomplete\n\nEditors that use the YAML language server (VS Code with the Red Hat YAML extension, Neovim, Zed, JetBrains) read the modeline on the first line and validate and complete the file against the schema:\n\n```yaml\n# yaml-language-server: $schema=https://openpresentation.org/schema/opf/v1\n```\n\n`opf to-yaml --schema-comment` writes it. A command that rewrites a YAML file keeps the file\'s own first-line modeline verbatim (whatever URL it names, even a local schema path) and generates one only for `--schema-comment`. It is a comment: `fromYaml` ignores it.\n\n## Other exports\n\nThese three exports are not part of the `fromYaml`/`toYaml` pair. They exist so the CLI and other tools can reuse the reader.\n\n| Export | Purpose |\n| --- | --- |\n| `parseYamlData(yaml, options)` | The same strict dialect for any JSON-compatible root, for files that are not decks (a JSON Patch, a settings file). `{ value, valid, findings }` |\n| `scanYamlComments(yaml)` | `{ count, modeline? }`: the comments a rewrite would lose, and the editor modeline (the first-line `# yaml-language-server:` comment, verbatim) |\n| `YAML_SCHEMA_URL` | The canonical schema URL |\n\n## Findings\n\n`fromYaml(text, options)` never throws for malformed content. It returns the deck and the [validate](validate.md) report of it, `{ presentation, valid, findings, counts, schemaValid, checks }` (plus `template` and `unfilledVariables` for a deck with content variables), so a YAML file reports exactly what the same deck as JSON text does. `presentation` is a best effort when `valid` is false (an empty object after a YAML error). Each finding is a [Finding](finding-schema-reference.md) (`ruleId`, `severity`, `category`, `path`, `scope`, `message`, `help`) plus `location` (`offset`, `length`, `line`, `column`). Options: `validate` (`true`, the default, checks `format` and `references`; `validate` options pick other rules or categories, and `{}` runs every rule; `false` skips the check, so `schemaValid` is null) and `aliases` (default false).\n\nYAML errors are `format` findings: `yaml/syntax`, `yaml/empty`, `yaml/not-mapping`, `yaml/multiple-documents`, `yaml/duplicate-key`, `yaml/key`, `yaml/tag`, `yaml/number`, `yaml/alias`, `yaml/alias-limit`, `yaml/alias-cycle`. After one, no OPF check runs (`schemaValid: null`). Ids starting `opf/` are the `validate` findings of the deck, located in the YAML.\n\n## Command line\n\n```text\nopf from-yaml <deck.yaml|-> [output.opf.json|-] [--aliases] [--force] [--fail-on <level>]\nopf to-yaml <deck.opf.json|-> [output.opf.yaml|-] [--schema-comment] [--force]\n```\n\nBoth write to stdout by default and print a JSON report (on stderr when stdout carries the document), as `from-md` and `to-md` do. Exit codes: 0 success, 1 invalid content, an output conflict or a finding at or above `--fail-on`, 2 usage, a read error or I/O. `from-yaml` exits 1 with `yaml.findings` (line and column) on a YAML or OPF error and writes nothing; `--fail-on warning` also fails on warnings. `to-yaml` exits 1 for a deck that is not valid OPF.\n\n### YAML everywhere\n\nEvery command that reads a deck or a JSON Patch reads YAML too, through one reader:\n\n1. A file whose name ends `.yaml` or `.yml` is YAML, parsed in the strict dialect (no aliases).\n2. Anything else (stdin and every other name) follows the global option `--input-format <json|yaml>`, with **JSON as the default**. The option may appear anywhere before `--`; it applies to every input read from stdin or from a name that does not end `.yaml`/`.yml`.\n3. A YAML syntax or dialect error exits **2**, like invalid JSON, and the message carries the position: `Invalid YAML in deck.opf.yaml at line 5, column 5: Map keys must be unique [yaml/duplicate-key]`, with the located findings under `yaml` in the JSON error report. `opf validate` instead reports a YAML error as a `yaml/<rule>` finding and exits 1, as it does for JSON that does not parse. A deck with a syntax-valid but invalid OPF structure is reported by the command as it is for JSON (`validate`, `render`, `export`...; `validate`, `render` and `export` locate their findings in the YAML).\n\nA deck is written as YAML when the output name ends `.yaml`/`.yml` or `--format yaml` is given (`--format json` forces JSON). When the output is stdout or has another name, the format of the deck that was read is used (a YAML deck is edited, merged, formatted, filled, paginated and bundled back to YAML); commands with no deck input (`create`, `from-md`, `import`) default to JSON. `opf format` canonicalizes a YAML file and converts with `--format`; `--check` works for both.\n\nLeft JSON-only on purpose: `validate --config` (a settings file), the `--data` values file of `fill` (CSV, TSV or JSON data, not a deck), the `import-data` source file, and `--signals` output. In `fill` and `import-data`, `--format csv|tsv|json` still names the data format; only `--format yaml` selects the output.\n\n**Comments are not preserved.** A command that rewrites a YAML file (`edit`, `format`, `merge`, `paginate`, `bundle`, `fill`, `import-data --into`, `to-yaml`) writes the canonical text of the data, so comments and the original quoting and layout are lost. The CLI prints `warning: <file> has N comments; comments are not preserved when OPF rewrites a YAML file.` on stderr, and does not fail. The `# yaml-language-server:` line is kept.\n\n## Decisions and limits\n\nThese choices are the defaults of the module and can be changed.\n\n- **JSON stays canonical and the interchange form.** The schemas, the catalogs, the reports, the renderer, the editor and the PPTX converter all take the JSON document; YAML is converted at the edge, by `fromYaml` and `toYaml`, and `opf from-yaml` gives you the JSON. Nothing in OPF depends on a YAML library at run time except this subpath and the Markdown front matter.\n- **Comments are not preserved by commands that rewrite.** The YAML is parsed to plain data and written again from the data, which is what makes the output canonical and the same deck always the same bytes. Preserving comments would need a lossless syntax tree through every edit and would make output depend on the history of the file. Use `opf from-yaml` / `opf to-yaml` or an editor to change a file that you annotate heavily, and keep the notes in `description`, `notes` or `extensions` when they should survive.\n- **Strict by default.** Anchors, aliases, merge keys, custom tags, duplicate keys, non-string keys and non-finite numbers are where YAML readers disagree (YAML 1.1 and 1.2, `<<`, `on`/`yes`, sexagesimal numbers) or where a document stops being JSON. A deck should mean the same thing in every tool, so the reader accepts the intersection and says where the text leaves it. `aliases: true` opts into expansion for files that already use it.\n- **pptx.dev\'s playground uses `aliases: true`** so that YAML pasted into it from other sources keeps working; the default of the module and of the CLI stays strict.\n- **One YAML reader.** The `yaml` package (ISC, no dependencies) reads and writes, with the core schema. The [Markdown](markdown.md) front matter and the `opf-slide` and `opf-block` fences follow the same rules (JSON-compatible YAML 1.2 core schema: no anchors, tags or duplicate keys).\n- **Block style only.** Tables and chart data are lists of lists, which block style spreads over many lines. The text is longer than the JSON but diffs line by line, and one style means one canonical form.\n- **Quoting is conservative.** The core schema reads `yes` and `2026-10-01` as text, but a YAML 1.1 reader does not, so the writer quotes them anyway.\n- **No new fields.** YAML adds nothing to the schema: there is no YAML-only key, and a file that does not validate as JSON does not validate as YAML.\n'
331
+ "markdown": '# OPF as YAML\n\n`@openpresentation/opf/yaml` (RR-56) reads and writes an OPF deck as YAML, in both directions, with no renderer, fonts, network or model: the same input always gives the same output. The CLI exposes it as `opf from-yaml` and `opf to-yaml`, and every command that reads or writes a deck understands `.opf.yaml` files.\n\n- **JSON stays canonical.** The schema, the interchange form and every package describe the JSON. A `.opf.yaml` file is an authoring serialization of the same data: the parsed YAML is exactly the JSON document, and the [schema](../spec/schemas/opf.schema.json) validates that parsed data.\n- **Write a deck by hand.** YAML has no braces, no quotes around keys and no trailing-comma errors, and a block scalar (`text: |`) keeps a paragraph readable. Comments are allowed in the file (they are not data).\n- **One strict dialect.** JSON-compatible YAML 1.2, core schema. Anything that could mean something else in another reader, or cannot exist in JSON, is an error with a line and a column.\n- **Round trip.** `toYaml` writes canonical text that `fromYaml` reads back to the same deck, and that converts back to itself byte for byte. Every one of the 127 example decks round-trips, JSON to YAML to JSON and YAML to JSON to YAML.\n- **Errors have places.** Findings use the shared Finding format of [validate](validate.md) and carry the source offset, length, line and column of the YAML that caused them, including the OPF findings mapped back to the YAML of the field they name.\n\n```js\nimport { fromYaml, toYaml } from "@openpresentation/opf/yaml";\n\nconst { presentation, valid, findings } = fromYaml(text);\nconst { yaml } = toYaml(presentation, { schemaComment: true });\n```\n\n```sh\nopf from-yaml deck.opf.yaml deck.opf.json\nopf to-yaml deck.opf.json deck.opf.yaml --schema-comment\nopf validate deck.opf.yaml\nopf render deck.opf.yaml --out slides\n```\n\n## An example\n\n```yaml\n# yaml-language-server: $schema=https://openpresentation.org/schema/opf/v1\n$schema: https://openpresentation.org/schema/opf/v1\nname: Q3 Business Review\nlanguage: en-US\nslides:\n - id: cover\n title: Q3 Business Review\n subtitle: Operations and growth\n notes: Two minutes on the agenda, then straight into the numbers.\n - title: Revenue grew every quarter\n blocks:\n - chart:\n type: column\n data:\n columns:\n - Quarter\n - Revenue\n rows:\n - - Q1\n - 12\n - - Q2\n - 18\n - quote:\n text: We would rather spend a week on capacity than a month on an outage.\n attribution: Priya Raman, Head of Platform\n```\n\nis the same deck as the JSON it converts to: `name`, `language` and `slides` with the values above. A comment and the first line are not data.\n\n## The dialect\n\n`fromYaml` accepts exactly this, and nothing else:\n\n| Rule | What happens otherwise |\n| --- | --- |\n| One document, with a mapping at the root | `yaml/empty` (nothing, or only comments), `yaml/multiple-documents` (a `---` separates two), `yaml/not-mapping` (a list or a bare value) |\n| YAML 1.2 core schema | `2026-10-01`, `yes`, `no`, `on`, `off` and `1_000` stay **strings**: only `true`/`false`, `null`/`~` and numbers (decimal, `0x1F`, `0o17`, exponents) are typed. Quote a value to force text |\n| No duplicate keys | `yaml/duplicate-key`, at the repeated key |\n| Keys are strings | `yaml/key` for `1:`, `true:`, `null:` or a collection used as a key. Quote it: `"1":` |\n| No custom tags | `yaml/tag` for `!custom`, `!!binary`, `!!set` and the like. The core tags `!!str`, `!!int`, `!!float`, `!!bool`, `!!null`, `!!map` and `!!seq` are allowed |\n| Finite numbers only | `yaml/number` for `.inf`, `-.inf` and `.nan`, with the hint to quote them |\n| No anchors, aliases or merge keys | `yaml/alias`, with help that points at `aliases: true` |\n| Syntax | `yaml/syntax` for anything the parser rejects, at the position it reports |\n\nA leading byte order mark and CRLF line endings are accepted. Offsets are UTF-16 units into the text you passed (a BOM counts as one); lines and columns are one-based and ignore the BOM. After the YAML parses, the deck goes through `validate` and each finding (`opf/...` rule ids) is located at the YAML node of the field it names: the value for a scalar, the key through the value for a mapping or a list, and the nearest existing ancestor for something that is missing.\n\n### `aliases: true`\n\nAnchors (`&name`), aliases (`*name`) and the `<<` merge key are valid YAML and handy for repeated values, but they are not JSON, many tools handle them differently, and an alias hides the size of what it expands to. They are refused by default. `fromYaml(text, { aliases: true })` (CLI: `opf from-yaml --aliases`) expands them to plain data:\n\n```yaml\nslides:\n - title: One\n extensions:\n owner: &owner { team: ops, oncall: true }\n - title: Two\n extensions:\n owner: *owner\n other:\n <<: *owner\n extra: 1\n```\n\nThe expansion is capped by the `yaml` package\'s `maxAliasCount` (100; each alias counts the nodes it expands to, so nested aliases that multiply exponentially are refused with `yaml/alias-limit`), and an alias that sits inside its own anchor is `yaml/alias-cycle`. What comes out is always finite, tree-shaped JSON data, and keys, tags and numbers are checked exactly as without the option. Writing never produces anchors or aliases.\n\n## Writing OPF as YAML\n\n`toYaml(presentation, { schemaComment })` first validates the presentation (`OPFYamlError`, `code: "invalid-document"`, when it is not valid OPF), then writes the canonical form:\n\n- keys in the order of the schema (the same order as [`opf format`](../packages/cli/README.md)): `$schema`, `name`, ..., `slides`, `assets`, ..., with keys the schema does not declare after them in their own order;\n- two-space indentation, block style throughout, no line folding (`lineWidth: 0`), a block scalar (`|`) for text with line breaks, one trailing newline, LF line endings;\n- strings that would read as another type, or as another type in a YAML 1.1 reader, are quoted: `"yes"`, `"No"`, `"on"`, `"2026-10-01"`, `"123"`, `"null"`, `"0x1F"`, `"12:30"`, and any string with leading or trailing spaces, a `#` or `: `. Mapping keys such as `"yes":` follow the same rule;\n- the text is **read back before it is returned**: it is parsed again in the strict dialect and compared with the input, key order included. If it does not read back to the same data, `toYaml` throws `OPFYamlError` with `code: "not-representable"` instead of writing something wrong (for example a lone surrogate in a string).\n\n`schemaComment: true` starts the file with `# yaml-language-server: $schema=<url>`, the deck\'s own `$schema` or else the canonical `https://openpresentation.org/schema/opf/v1`.\n\n### Editor autocomplete\n\nEditors that use the YAML language server (VS Code with the Red Hat YAML extension, Neovim, Zed, JetBrains) read the modeline on the first line and validate and complete the file against the schema:\n\n```yaml\n# yaml-language-server: $schema=https://openpresentation.org/schema/opf/v1\n```\n\n`opf to-yaml --schema-comment` writes it. A command that rewrites a YAML file keeps the file\'s own first-line modeline verbatim (whatever URL it names, even a local schema path) and generates one only for `--schema-comment`. It is a comment: `fromYaml` ignores it.\n\n## Other exports\n\nThese three exports are not part of the `fromYaml`/`toYaml` pair. They exist so the CLI and other tools can reuse the reader.\n\n| Export | Purpose |\n| --- | --- |\n| `parseYamlData(yaml, options)` | The same strict dialect for any JSON-compatible root, for files that are not decks (a JSON Patch, a settings file). `{ value, valid, findings }` |\n| `scanYamlComments(yaml)` | `{ count, modeline? }`: the comments a rewrite would lose, and the editor modeline (the first-line `# yaml-language-server:` comment, verbatim) |\n| `YAML_SCHEMA_URL` | The canonical schema URL |\n\n## Findings\n\n`fromYaml(text, options)` never throws for malformed content. It returns the deck and the [validate](validate.md) report of it, `{ presentation, valid, findings, counts, schemaValid, checks }` (plus `template` and `unfilledVariables` for a deck with content variables), so a YAML file reports exactly what the same deck as JSON text does. `presentation` is a best effort when `valid` is false (an empty object after a YAML error). Each finding is a [Finding](finding-schema-reference.md) (`ruleId`, `severity`, `category`, `path`, `scope`, `message`, `help`) plus `location` (`offset`, `length`, `line`, `column`). Options: `validate` (`true`, the default, checks `format` and `references`; `validate` options pick other rules or categories, and `{}` runs every rule; `false` skips the check, so `schemaValid` is null) and `aliases` (default false).\n\nYAML errors are `format` findings: `yaml/syntax`, `yaml/empty`, `yaml/not-mapping`, `yaml/multiple-documents`, `yaml/duplicate-key`, `yaml/key`, `yaml/tag`, `yaml/number`, `yaml/alias`, `yaml/alias-limit`, `yaml/alias-cycle`. After one, no OPF check runs (`schemaValid: null`). Ids starting `opf/` are the `validate` findings of the deck, located in the YAML.\n\n## Command line\n\n```text\nopf from-yaml <deck.yaml|-> [output.opf.json|-] [--aliases] [--force] [--fail-on <level>]\nopf to-yaml <deck.opf.json|-> [output.opf.yaml|-] [--schema-comment] [--force]\n```\n\nBoth write to stdout by default and print a JSON report (on stderr when stdout carries the document), as `from-md` and `to-md` do. Exit codes: 0 success, 1 invalid content, an output conflict or a finding at or above `--fail-on`, 2 usage, a read error or I/O. `from-yaml` exits 1 with `yaml.findings` (line and column) on a YAML or OPF error and writes nothing; `--fail-on warning` also fails on warnings. `to-yaml` exits 1 for a deck that is not valid OPF.\n\n### YAML everywhere\n\nEvery command that reads a deck or a JSON Patch reads YAML too, through one reader:\n\n1. A file whose name ends `.yaml` or `.yml` is YAML, parsed in the strict dialect (no aliases).\n2. Anything else (stdin and every other name) follows the global option `--input-format <json|yaml>`, with **JSON as the default**. The option may appear anywhere before `--`; it applies to every input read from stdin or from a name that does not end `.yaml`/`.yml`.\n3. A YAML syntax or dialect error exits **2**, like invalid JSON, and the message carries the position: `Invalid YAML in deck.opf.yaml at line 5, column 5: Map keys must be unique [yaml/duplicate-key]`, with the located findings under `yaml` in the JSON error report. `opf validate` instead reports a YAML error as a `yaml/<rule>` finding and exits 1, as it does for JSON that does not parse. A deck with a syntax-valid but invalid OPF structure is reported by the command as it is for JSON (`validate`, `render`, `export`...; `validate`, `render` and `export` locate their findings in the YAML).\n\nA deck is written as YAML when the output name ends `.yaml`/`.yml` or `--format yaml` is given (`--format json` forces JSON). When the output is stdout or has another name, the format of the deck that was read is used (a YAML deck is edited, merged, formatted, filled, paginated and bundled back to YAML); commands with no deck input (`create`, `from-md`, `import`) default to JSON. `opf format` canonicalizes a YAML file and converts with `--format`; `--check` works for both.\n\nLeft JSON-only on purpose: `validate --config` (a settings file), the `--data` values file of `fill` (CSV, TSV or JSON data, not a deck), the `import-data` source file, and `--signals` output. In `fill` and `import-data`, `--format csv|tsv|json` still names the data format; only `--format yaml` selects the output.\n\n**Comments are not preserved.** A command that rewrites a YAML file (`edit`, `format`, `merge`, `paginate`, `bundle`, `fill`, `import-data --into`, `to-yaml`) writes the canonical text of the data, so comments and the original quoting and layout are lost. The CLI prints `warning: <file> has N comments; comments are not preserved when OPF rewrites a YAML file.` on stderr, and does not fail. The `# yaml-language-server:` line is kept.\n\n## Decisions and limits\n\nThese choices are the defaults of the module and can be changed.\n\n- **JSON stays canonical and the interchange form.** The schemas, the catalogs, the reports, the renderer, the editor and the PPTX converter all take the JSON document; YAML is converted at the edge, by `fromYaml` and `toYaml`, and `opf from-yaml` gives you the JSON. Nothing in OPF depends on a YAML library at run time except this subpath and the Markdown front matter.\n- **Comments are not preserved by commands that rewrite.** The YAML is parsed to plain data and written again from the data, which is what makes the output canonical and the same deck always the same bytes. Preserving comments would need a lossless syntax tree through every edit and would make output depend on the history of the file. Use `opf from-yaml` / `opf to-yaml` or an editor to change a file that you annotate heavily, and keep the notes in `description`, `notes` or `extensions` when they should survive.\n- **Strict by default.** Anchors, aliases, merge keys, custom tags, duplicate keys, non-string keys and non-finite numbers are where YAML readers disagree (YAML 1.1 and 1.2, `<<`, `on`/`yes`, sexagesimal numbers) or where a document stops being JSON. A deck should mean the same thing in every tool, so the reader accepts the intersection and says where the text leaves it. `aliases: true` opts into expansion for files that already use it.\n- **pptx.dev\'s playground uses `aliases: true`** so that YAML pasted into it from other sources keeps working; the default of the module and of the CLI stays strict.\n- **One YAML reader.** The `yaml` package (ISC, no dependencies) reads and writes, with the core schema. The [Markdown](markdown.md) front matter and the `opf-slide` and `opf-block` fences follow the same rules (JSON-compatible YAML 1.2 core schema: no anchors, tags or duplicate keys).\n- **Block style only.** Tables and chart data are lists of lists, which block style spreads over many lines. The text is longer than the JSON but diffs line by line, and one style means one canonical form.\n- **Quoting is conservative.** The core schema reads `yes` and `2026-10-01` as text, but a YAML 1.1 reader does not, so the writer quotes them anyway.\n- **No new fields.** YAML adds nothing to the schema: there is no YAML-only key, and a file that does not validate as JSON does not validate as YAML.\n'
332
332
  }
333
333
  ]);
334
334
  var docsRaw = docsData;