diff --git a/packages/preview/sang-math/1.0.0/LICENSE b/packages/preview/sang-math/1.0.0/LICENSE new file mode 100644 index 0000000000..bd64100d3a --- /dev/null +++ b/packages/preview/sang-math/1.0.0/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Sang Nguyen + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/preview/sang-math/1.0.0/README.md b/packages/preview/sang-math/1.0.0/README.md new file mode 100644 index 0000000000..b69fa73620 --- /dev/null +++ b/packages/preview/sang-math/1.0.0/README.md @@ -0,0 +1,210 @@ +# sang-math + +A comprehensive Typst toolkit designed primarily for typesetting high school mathematics and general mathematical documents (focused on the Vietnamese curriculum). + +Bộ macro Toán THPT Việt Nam — Bảng biến thiên, Bảng xét dấu, Trắc nghiệm, Hình học CeTZ. + +📖 Tài liệu đầy đủ: https://hdsd-conictypst.pages.dev + +## Cài đặt + +```typ +#import "@preview/sang-math:1.0.0": * +``` + +## Các macro chính + +### 1. Bảng biến thiên (`bbtv2`) + +```typ +#import "@preview/sang-math:1.0.0": bbtv2 + +#bbtv2( + x-vals: ($-oo$, $-1$, $1$, $+oo$), + d-signs: ("+", 0, "-", 0, "+"), + v-vals: ($-oo$, $3$, $-1$, $+oo$), +) +``` + +### 2. Bảng biến thiên đầy đủ (`bbbt`) — có hàng `f(x)` và `ranks` + +```typ +#import "@preview/sang-math:1.0.0": bbbt + +#bbbt( + x-vals: ($-oo$, $0$, $+oo$), + d-signs: ("-", z, "+"), + v-vals: ($+oo$, $0$, $+oo$), + ranks: ("CB", none, "CT"), +) +``` + +### 3. Bảng xét dấu (`bxd`) + +```typ +#import "@preview/sang-math:1.0.0": bxd + +#bxd( + x-vals: ($-oo$, $-2$, $1$, $+oo$), + rows: ( + ($x + 2$, "-", z, "+", "|", "+"), + ($x - 1$, "-", "|", "-", z, "+"), + ($f(x)$, "+", z, "-", z, "+"), + ), +) +``` + +### 4. Câu trắc nghiệm (`tn`) + +```typ +#import "@preview/sang-math:1.0.0": tn, sang-setup, True + +#show: sang-setup + +#tn( + [Đạo hàm của hàm số $f(x) = x^3 - 3x + 1$ tại $x = 2$ bằng], + ([$3$], True([$9$]), [$6$], [$-3$]), + loigiai: [$f'(x) = 3x^2 - 3 Rightarrow f'(2) = 9$], +) +``` + +### 5. Câu tự luận có lời giải (`tl`) + +```typ +#import "@preview/sang-math:1.0.0": tl, sang-setup + +#show: sang-setup + +#tl( + [Giải phương trình $2^x = 8$.], + loigiai: [$2^x = 2^3 Rightarrow x = 3$], +) +``` + +### 6. Template đề thi đẹp + +```typ +#import "@preview/sang-math:1.0.0": * + +#let theme = "royal" +#let profile = "dethi" // "dethi" | "loigiai" | "compact" | "draft" | "beamer" +#let opt-style = auto // auto = "plain" = A. B. C. D. +// #let opt-style = "theme" // theo theme +// #let opt-style = "circle" // khoanh tròn + +#let preset = exam-preset( + theme: theme, + profile: profile, + opt-style: opt-style, +) +#let (tn, ds, tln, tl) = exam-mode(..preset.question) + +#show: sang-setup.with(math-color: preset.accent) +#show: exam-theme.with( + theme: preset.theme, + school: "TRƯỜNG THPT SANG-MATH", + exam-title: "ĐỀ THI THỬ THPT", + subject: "TOÁN 12", + duration: "90 phút", + code: "101", + ..preset.template, +) +``` + +Các theme có sẵn: `classic`, `ocean`, `emerald`, `royal`, `violet`, `crimson`, `graphite`, `amber`, `teal-pro`, `sky`, `indigo-minimal`, `print-economy`, `aurora`, `lotus`, `navy-gold`, `jade`, `coral`, `plum`. + +Profile nhanh: + +- `profile: "dethi"`: in đề học sinh. +- `profile: "loigiai"`: in đề có lời giải và tự bật bảng đáp án cuối đề. +- `profile: "compact"`: dàn 2 cột. +- `profile: "draft"`: thêm cột nháp bên phải từng câu. +- `profile: "beamer"`: bỏ header/trang in để cùng nội dung chuyển sang `sang-beamer.typ`. + +Tùy chọn nổi bật: + +- `mode: "dethi"` hoặc `"loigiai"` để in đề hoặc in kèm lời giải. +- `opt-style: auto` hoặc `"plain"` là kiểu nguyên thuỷ `A. B. C. D.`. +- `opt-style: "theme"` là tự lấy kiểu đẹp theo theme. Ví dụ `teal-pro` dùng `solid-pentagon`. +- `opt-style: "circle" | "double-circle" | "solid-circle" | "square" | "solid-square" | "rounded-square" | "diamond" | "solid-diamond" | "triangle" | "solid-triangle" | "pentagon" | "solid-pentagon" | "hexagon" | "solid-hexagon" | "badge"` cho nhãn A, B, C, D. Các kiểu cụ thể phải viết trong dấu nháy. +- `opt-label-color: rgb(...)` để ép màu riêng cho nhãn A, B, C, D. +- `q-label-style: "plain" | "pill" | "solid-pill" | "badge" | "ribbon" | "flag" | "underline" | "spark"` cho nhãn Câu. +- `show-tags: false` để ẩn tag phân loại ở mép phải. +- `draft: true`, `draft-width: 28%`, `draft-lines: 6` để thêm cột nháp bên phải từng câu. +- `two-columns: true` trong template để dàn đề 2 cột. +- `answer-key: true` trong template để in bảng đáp án cuối đề. Dùng `profile: "loigiai"` thì preset tự bật. + +Mẫu công tắc ngay trong file: + +```typ +#let profile = "dethi" // đề học sinh +// #let profile = "loigiai" // lời giải + bảng đáp án +// #let profile = "compact" // đề 2 cột +// #let profile = "draft" // có nháp bên phải +// #let profile = "beamer" // sẵn chuyển sang slide + +#let theme = "teal-pro" +// #let theme = "aurora" +// #let theme = "navy-gold" + +#let opt-style = auto // nguyên thuỷ A. B. C. D. +// #let opt-style = "theme" // theo theme +// #let opt-style = "circle" +// #let opt-style = "hexagon" +// #let opt-style = "solid-hexagon" +``` + +File demo đầy đủ nằm tại `examples/exam-template-demo.typ`, gồm 12 câu trắc nghiệm, 4 câu đúng/sai, 6 câu trả lời ngắn và 3 câu tự luận. + +Script kiểm tra nội bộ cho người phát triển package: + +```bash +typst-pkg-sang-math/tests/compile-exam-templates.sh +``` + +### 7. Chế độ hiển thị đề thi (`exam-mode`) + +```typ +#import "@preview/sang-math:1.0.0": exam-mode, sang-setup + +#let (tn, ds, tln, tl) = exam-mode(mode: "dethi") +#show: sang-setup +``` + +### 8. Ký hiệu toán học tắt (`math-sym`) + +```typ +#import "@preview/sang-math:1.0.0": * + +// Các ký hiệu: vô cực, tập hợp, mũi tên... +$+oo$, $-oo$, $RR$, $ZZ$, $NN$, $QQ$ +$=>$, $<=>$, $forall$, $exists$ +``` + +### 9. Hình học CeTZ (`geometry`) + +```typ +#import "@preview/sang-math:1.0.0": tri-abc + +// Tam giác ABC với đỉnh tùy chỉnh +#tri-abc() +``` + +## Tùy chọn bbt-opt + +```typ +#import "@preview/sang-math:1.0.0": bbt-opt, bbtv2 + +// Đặt tùy chọn toàn cục +#bbt-opt( + var: $t$, // Tên biến (mặc định: $x$) + der: $f'(t)$, // Ký hiệu đạo hàm + func: $f(t)$, // Tên hàm số + w1: 1.2, // Chiều rộng cột tên + w2: 12, // Chiều rộng mỗi khoảng +) +``` + +## License + +MIT © 2024 Sang Nguyen diff --git a/packages/preview/sang-math/1.0.0/bbt.typ b/packages/preview/sang-math/1.0.0/bbt.typ new file mode 100644 index 0000000000..197a7c10dc --- /dev/null +++ b/packages/preview/sang-math/1.0.0/bbt.typ @@ -0,0 +1,739 @@ +#import "@preview/cetz:0.5.2": canvas, draw +// ========================================== +// BỘ MACRO HOÀN MỸ CHO BẢNG BIẾN THIÊN VÀ BẢNG XÉT DẤU +// Cập nhật tính năng: +// 1. shade: Tô vùng không xác định (gạch chéo) chuẩn tkz-tab +// 2. Tự động ngắt mũi tên đi qua vùng không xác định +// 3. Tích hợp thêm macro bxd (Bảng xét dấu) siêu gọn nhẹ +// ========================================== + +#let bbtv2( + var: $x$, + der: $y'$, + func: $y$, + x-vals: (), + d-signs: (), + v-vals: (), + shade: (), // Mảng chứa các cặp index vùng gạch chéo. VD: ((1, 2),) + w1: 1.5, + w2: 10, + h1: auto, + h2: 0.8, + h3: auto, +) = context { + let __clr = text.fill + // Tự động phân tích AST để điều chỉnh chiều cao nếu h1, h3 là auto + let h1 = if h1 == auto { + let has-tall = x-vals.any(x => { + let r = repr(x) + r.contains("frac(") or r.contains("integral(") or r.contains("lim(") or r.contains("cases(") + }) + if has-tall { 1.2 } else { 0.8 } + } else { h1 } + + let h3 = if h3 == auto { + let has-tall = v-vals.any(v => { + if type(v) == array { + let r = repr(v.at(0)) + repr(v.at(1)) + r.contains("frac(") or r.contains("integral(") or r.contains("lim(") or r.contains("cases(") + } else { + let r = repr(v) + r.contains("frac(") or r.contains("integral(") or r.contains("lim(") or r.contains("cases(") + } + }) + if has-tall { 2.8 } else { 2.2 } + } else { h3 } + + canvas(length: 1cm, { + import draw: * + + let n = x-vals.len() + let tw = w1 + w2 + let th = h1 + h2 + h3 + + // Tọa độ x cho các cột nội dung + let x-pos = () + for i in range(n) { + let px = w1 + 0.6 + (w2 - 1.2) * i / (n - 1) + x-pos.push(px) + } + + // 1. Kẻ khung cơ bản + rect((0, 0), (tw, -th), stroke: 1pt + __clr) + line((0, -h1), (tw, -h1), stroke: 1pt + __clr) + line((0, -h1 - h2), (tw, -h1 - h2), stroke: 1pt + __clr) + line((w1, 0), (w1, -th), stroke: 1pt + __clr) + + // 2. Xử lý vùng Shade (gạch chéo vùng không xác định) + let hatch = tiling(size: (8pt, 8pt))[ + #std.line(start: (0pt, 8pt), end: (8pt, 0pt), stroke: rgb("888888") + 0.5pt) + ] + for s in shade { + let xL = x-pos.at(s.at(0)) + let xR = x-pos.at(s.at(1)) + rect((xL, -h1), (xR, -th), fill: hatch, stroke: none) + line((xL, -h1 - h2), (xR, -h1 - h2), stroke: 1pt + __clr) + } + + // Nhãn cột trái + content((w1 / 2, -h1 / 2), var) + content((w1 / 2, -h1 - h2 / 2), der) + content((w1 / 2, -h1 - h2 - h3 / 2), func) + + let sign-of(s) = { + let r = repr(s) + if s == "+" or s == $+$ or r.contains("+") { "+" } + else if s == "-" or s == $-$ or r.contains("−") or r.contains("-") { "-" } + else if s == "||" or r.contains("||") or r.contains("‖") or r.contains("parallel") { "||" } + else if s == "" or s == [] or s == none or r == "\"\"" or r == "[]" or r == "none" or s == " " { "" } + else { "0" } + } + let render-sign(s) = { + let sig = sign-of(s) + if sig == "-" { $-$ } else if sig == "+" { $+$ } else if sig == "0" { $0$ } else if sig == "||" { none } else if sig == "" { none } else { s } + } + + for i in range(n) { + content((x-pos.at(i), -h1 / 2), x-vals.at(i)) + } + + let is-inf(v) = { if type(v) != content { false } else { repr(v).contains("oo") } } + + // Hàng 2: dấu đạo hàm và khoảng + for i in range(n) { + if i > 0 and i < n - 1 { + let sign-idx = 2 * i - 1 + if sign-of(d-signs.at(sign-idx)) == "||" { + let px = x-pos.at(i) + let is-double = type(v-vals.at(i)) == array or v-vals.at(i) == "||" or v-vals.at(i) == none or (type(v-vals.at(i)) == content and (repr(v-vals.at(i)).contains("|") or repr(v-vals.at(i)).contains("||"))) + if is-double { + line((px - 0.05, -h1), (px - 0.05, -th), stroke: 0.8pt + __clr) + line((px + 0.05, -h1), (px + 0.05, -th), stroke: 0.8pt + __clr) + } else { + line((px - 0.05, -h1), (px - 0.05, -h1 - h2), stroke: 0.8pt + __clr) + line((px + 0.05, -h1), (px + 0.05, -h1 - h2), stroke: 0.8pt + __clr) + } + } else { + content((x-pos.at(i), -h1 - h2 / 2), render-sign(d-signs.at(sign-idx))) + } + } + if i < n - 1 { + let is-shaded = false + for s in shade { if i >= s.at(0) and i < s.at(1) { is-shaded = true } } + if not is-shaded { + content(((x-pos.at(i) + x-pos.at(i + 1)) / 2, -h1 - h2 / 2), render-sign(d-signs.at(2 * i))) + } + } + } + + // Hàng 3: rank → map-y + // Rank luôn được tính kể cả qua vùng shade; chỉ mũi tên mới bị ẩn + let ranks = () + let cur = 0 + ranks.push((cur,)) + for i in range(n - 1) { + let sign = sign-of(d-signs.at(2 * i)) + if sign == "+" { cur += 1 } else if sign == "-" { cur -= 1 } + let next_idx = 2 * i + 1 + if next_idx < d-signs.len() and sign-of(d-signs.at(next_idx)) == "||" { + let val = v-vals.at(i + 1) + if type(val) == array { + // Tại tiệm cận đứng: bên trái và bên phải có rank riêng + // Dấu sau || quyết định chiều của đoạn tiếp theo + let ns = if next_idx + 1 < d-signs.len() { sign-of(d-signs.at(next_idx + 1)) } else { "+" } + let rL = cur // bên trái tiệm cận: rank hiện tại + // bên phải: ngược chiều so với bên trái để tạo gián đoạn + let rR = if ns == "-" { rL + 2 } else { rL - 2 } + cur = rR + ranks.push((rL, rR)) + } else { ranks.push((cur,)) } + } else { ranks.push((cur,)) } + } + let flat-r = () + for r in ranks { for v in r { if v != none { flat-r.push(v) } } } + let min-r = if flat-r.len() > 0 { calc.min(..flat-r) } else { 0 } + let max-r = if flat-r.len() > 0 { calc.max(..flat-r) } else { 0 } + let y-top = -h1 - h2 - 0.5 + let y-bot = -th + 0.4 + let map-y(r) = { + if max-r == min-r { (y-top + y-bot) / 2 } else { y-bot + (r - min-r) / (max-r - min-r) * (y-top - y-bot) } + } + + // Vẽ labels + for i in range(n) { + let rv = ranks.at(i) + let val = v-vals.at(i) + let px = x-pos.at(i) + if rv.len() == 1 { + let y = map-y(rv.at(0)) + let v-text = if type(val) == array { val.at(0) } else { val } + if not is-inf(v-text) and val != "" and val != none { + content((px, y), v-text, name: "v" + str(i), padding: 0.15) + } + } else { + let yL = map-y(rv.at(0)) + let yR = map-y(rv.at(1)) + let vL = if type(val) == array and val.len() > 0 { val.at(0) } else { val } + let vR = if type(val) == array and val.len() > 1 { val.at(1) } else { val } + let off = 0.15 + if not is-inf(vL) { content((px - off, yL), vL, name: "v" + str(i) + "L", padding: 0.15, anchor: "east") } + if not is-inf(vR) { content((px + off, yR), vR, name: "v" + str(i) + "R", padding: 0.15, anchor: "west") } + } + } + + // Tính anchor (tách khỏi render để tránh cetz/string join) + let node-anchors = () + for i in range(n) { + let rv = ranks.at(i) + let val = v-vals.at(i) + let px = x-pos.at(i) + if rv.len() == 1 { + let y = map-y(rv.at(0)) + let v-text = if type(val) == array { val.at(0) } else { val } + if not is-inf(v-text) and val != "" and val != none { + node-anchors.push(("v" + str(i), "v" + str(i))) + } else { + node-anchors.push(((px, y), (px, y))) + } + } else { + let yL = map-y(rv.at(0)) + let yR = map-y(rv.at(1)) + let vL = if type(val) == array and val.len() > 0 { val.at(0) } else { val } + let vR = if type(val) == array and val.len() > 1 { val.at(1) } else { val } + let off = 0.15 + let aL = if is-inf(vL) { (px - off, yL) } else { "v" + str(i) + "L" } + let aR = if is-inf(vR) { (px + off, yR) } else { "v" + str(i) + "R" } + node-anchors.push((aL, aR)) + } + } + + // Mũi tên + for i in range(n - 1) { + let is-shaded = false + for s in shade { if i >= s.at(0) and i < s.at(1) { is-shaded = true } } + if not is-shaded { + let s = node-anchors.at(i).at(1) + let e = node-anchors.at(i + 1).at(0) + if s != none and e != none { + line(s, e, mark: (end: ">", fill: __clr), stroke: 0.8pt + __clr) + } + } + } + }) +} + + +// ========================================== +// MACRO BẢNG XÉT DẤU (DÀNH CHO XÉT DẤU y', BẤT PHƯƠNG TRÌNH) +// ========================================== +// bxd: Bảng xét dấu — hỗ trợ nhiều dòng func +// func có thể là content đơn hoặc mảng content (nhiều dòng) +// f-signs: mảng mảng nếu nhiều dòng, hoặc mảng đơn nếu 1 dòng +#let bxd( + var: $x$, + func: $f(x)$, // content đơn hoặc mảng content cho nhiều dòng + x-vals: (), + f-signs: (), // mảng đơn hoặc mảng-của-mảng cho nhiều dòng + w1: 1.5, // chiều rộng cột nhãn trái — tăng nếu nhãn dài + w2: 8, + h1: 0.8, + h2: 0.8, // chiều cao mỗi dòng dấu +) = context { + let __clr = text.fill + // Chuẩn hóa: nếu func là mảng thì nhiều dòng + let funcs = if type(func) == array { func } else { (func,) } + let signs-list = if type(f-signs) == array and f-signs.len() > 0 and type(f-signs.at(0)) == array { + f-signs + } else { + (f-signs,) + } + let nrows = funcs.len() + + canvas(length: 1cm, { + import draw: * + let n = x-vals.len() + let tw = w1 + w2 + let th = h1 + h2 * nrows + + rect((0, 0), (tw, -th), stroke: 1pt + __clr) + line((0, -h1), (tw, -h1), stroke: 1pt + __clr) + line((w1, 0), (w1, -th), stroke: 1pt + __clr) + // Đường ngang giữa các dòng dấu (nếu > 1 dòng) + for r in range(1, nrows) { + line((0, -h1 - h2 * r), (tw, -h1 - h2 * r), stroke: 0.5pt + luma(150)) + } + + content((w1 / 2, -h1 / 2), var) + // Nhãn cột trái cho từng dòng + for r in range(nrows) { + content((w1 / 2, -h1 - h2 * r - h2 / 2), funcs.at(r)) + } + + let x-pos = () + for i in range(n) { + let px = w1 + 0.6 + (w2 - 1.2) * i / (n - 1) + x-pos.push(px) + content((px, -h1 / 2), x-vals.at(i)) + } + + let sign-of(s) = { + let r = repr(s) + if s == "+" or s == $+$ or r.contains("+") { "+" } + else if s == "-" or s == $-$ or r.contains("−") or r.contains("-") { "-" } + else if s == "||" or r.contains("||") or r.contains("‖") or r.contains("parallel") { "||" } + else if s == "" or s == [] or s == none or r == "\"\"" or r == "[]" or r == "none" or s == " " { "" } + else { "0" } + } + let render-sign(s) = { + let sig = sign-of(s) + if sig == "-" { $-$ } else if sig == "+" { $+$ } else if sig == "0" { $0$ } else if sig == "||" { none } else if sig == "" { none } else { s } + } + + for r in range(nrows) { + let row-signs = signs-list.at(r) + let y-mid = -h1 - h2 * r - h2 / 2 + let y-top-row = -h1 - h2 * r + let y-bot-row = -h1 - h2 * r - h2 + for i in range(n) { + if i > 0 and i < n - 1 { + let sign-idx = 2 * i - 1 + if row-signs.len() > sign-idx and sign-of(row-signs.at(sign-idx)) == "||" { + let px = x-pos.at(i) + line((px - 0.05, y-top-row), (px - 0.05, y-bot-row), stroke: 0.8pt + __clr) + line((px + 0.05, y-top-row), (px + 0.05, y-bot-row), stroke: 0.8pt + __clr) + } else if row-signs.len() > sign-idx { + content((x-pos.at(i), y-mid), render-sign(row-signs.at(sign-idx))) + } + } + if i < n - 1 { + let mid-x = (x-pos.at(i) + x-pos.at(i + 1)) / 2 + content((mid-x, y-mid), render-sign(row-signs.at(2 * i))) + } + } + } + }) +} + +#let bbbt( + var: $x$, + der: $y'$, + func: $y$, + x-vals: (), + d-signs: (), + v-vals: (), + ranks: none, + w1: 1.5, + w2: 11.5, + h1: auto, + h2: 0.98, + h3: auto, +) = context { + let __clr = text.fill + // Tự động phân tích AST để điều chỉnh chiều cao nếu h1, h3 là auto + let h1 = if h1 == auto { + let has-tall = x-vals.any(x => { + let r = repr(x) + r.contains("frac(") or r.contains("integral(") or r.contains("lim(") or r.contains("cases(") + }) + if has-tall { 1.2 } else { 0.98 } + } else { h1 } + + let h3 = if h3 == auto { + let has-tall = v-vals.any(v => { + if type(v) == array { + let r = repr(v.at(0)) + repr(v.at(1)) + r.contains("frac(") or r.contains("integral(") or r.contains("lim(") or r.contains("cases(") + } else { + let r = repr(v) + r.contains("frac(") or r.contains("integral(") or r.contains("lim(") or r.contains("cases(") + } + }) + if has-tall { 3.2 } else { 2.8 } + } else { h3 } + + canvas(length: 1cm, { + import draw: * + + let n = x-vals.len() + let tw = w1 + w2 + let th = h1 + h2 + h3 + + // Kẻ khung + rect((0, 0), (tw, -th), stroke: 1pt + __clr) + line((0, -h1), (tw, -h1), stroke: 1pt + __clr) + line((0, -h1 - h2), (tw, -h1 - h2), stroke: 1pt + __clr) + line((w1, 0), (w1, -th), stroke: 1pt + __clr) + + content((w1 / 2, -h1 / 2), var) + content((w1 / 2, -h1 - h2 / 2), der) + content((w1 / 2, -h1 - h2 - h3 / 2), func) + + let sign-of(s) = { + let r = repr(s) + if s == "+" or s == $+$ or r.contains("+") { "+" } + else if s == "-" or s == $-$ or r.contains("−") or r.contains("-") { "-" } + else if s == "||" or r.contains("||") or r.contains("‖") or r.contains("parallel") { "||" } + else if s == "" or s == [] or s == none or r == "\"\"" or r == "[]" or r == "none" or s == " " { "" } + else { "0" } + } + let render-sign(s) = { + let sig = sign-of(s) + if sig == "-" { $-$ } else if sig == "+" { $+$ } else if sig == "0" { $0$ } else if sig == "||" { none } else if sig == "" { none } else { s } + } + + let x-pos = () + for i in range(n) { + let px = w1 + 0.6 + (w2 - 1.2) * i / (n - 1) + x-pos.push(px) + content((px, -h1 / 2), x-vals.at(i)) + } + + let is-inf(v) = { if type(v) != content { false } else { repr(v).contains("oo") } } + + // Hàng 2: dấu đạo hàm + for i in range(n) { + if i > 0 and i < n - 1 { + let sign-idx = 2 * i - 1 + if sign-of(d-signs.at(sign-idx)) == "||" { + let px = x-pos.at(i) + let is-double = type(v-vals.at(i)) == array or v-vals.at(i) == "||" or v-vals.at(i) == none or (type(v-vals.at(i)) == content and (repr(v-vals.at(i)).contains("|") or repr(v-vals.at(i)).contains("||"))) + if is-double { + line((px - 0.05, -h1), (px - 0.05, -th), stroke: 0.8pt + __clr) + line((px + 0.05, -h1), (px + 0.05, -th), stroke: 0.8pt + __clr) + } else { + line((px - 0.05, -h1), (px - 0.05, -h1 - h2), stroke: 0.8pt + __clr) + line((px + 0.05, -h1), (px + 0.05, -h1 - h2), stroke: 0.8pt + __clr) + } + } else { + content((x-pos.at(i), -h1 - h2 / 2), render-sign(d-signs.at(sign-idx))) + } + } + if i < n - 1 { + content(((x-pos.at(i) + x-pos.at(i + 1)) / 2, -h1 - h2 / 2), render-sign(d-signs.at(2 * i))) + } + } + + // Hàng 3: rank → map-y + let ranks = if ranks != none { + ranks.map(r => if type(r) == array { r } else { (r,) }) + } else { + let computed = () + let cur = 0 + computed.push((cur,)) + for i in range(n - 1) { + let sign = sign-of(d-signs.at(2 * i)) + if sign == "+" { cur += 1 } else if sign == "-" { cur -= 1 } + let next_idx = 2 * i + 1 + if next_idx < d-signs.len() and sign-of(d-signs.at(next_idx)) == "||" { + let val = v-vals.at(i + 1) + if type(val) == array { + let ns = if next_idx + 1 < d-signs.len() { sign-of(d-signs.at(next_idx + 1)) } else { "+" } + let lr = cur + cur = if ns == "-" { lr + 2 } else { lr - 2 } + computed.push((lr, cur)) + } else { computed.push((cur,)) } + } else { computed.push((cur,)) } + } + computed + } + let flat-r = () + for r in ranks { for v in r { flat-r.push(v) } } + let min-r = calc.min(..flat-r) + let max-r = calc.max(..flat-r) + let y-top = -h1 - h2 - 0.5 + let y-bot = -th + 0.4 + let map-y(r) = { + if max-r == min-r { (y-top + y-bot) / 2 } else { y-bot + (r - min-r) / (max-r - min-r) * (y-top - y-bot) } + } + + // Vẽ labels + for i in range(n) { + let rv = ranks.at(i) + let val = v-vals.at(i) + let px = x-pos.at(i) + if rv.len() == 1 { + let y = map-y(rv.at(0)) + let v-text = if type(val) == array { val.at(0) } else { val } + if not is-inf(v-text) and val != "" and val != none { + content((px, y), v-text, name: "v" + str(i), padding: 0.15) + } + } else { + let yL = map-y(rv.at(0)) + let yR = map-y(rv.at(1)) + let vL = if type(val) == array and val.len() > 0 { val.at(0) } else { val } + let vR = if type(val) == array and val.len() > 1 { val.at(1) } else { val } + let off = 0.15 + if not is-inf(vL) { content((px - off, yL), vL, name: "v" + str(i) + "L", padding: 0.15, anchor: "east") } + if not is-inf(vR) { content((px + off, yR), vR, name: "v" + str(i) + "R", padding: 0.15, anchor: "west") } + } + } + + // Tính anchor (tách loop để tránh cetz/string join) + let node-anchors = () + for i in range(n) { + let rv = ranks.at(i) + let val = v-vals.at(i) + let px = x-pos.at(i) + if rv.len() == 1 { + let y = map-y(rv.at(0)) + let v-text = if type(val) == array { val.at(0) } else { val } + if not is-inf(v-text) and val != "" and val != none { + node-anchors.push(("v" + str(i), "v" + str(i))) + } else { + node-anchors.push(((px, y), (px, y))) + } + } else { + let yL = map-y(rv.at(0)) + let yR = map-y(rv.at(1)) + let vL = if type(val) == array and val.len() > 0 { val.at(0) } else { val } + let vR = if type(val) == array and val.len() > 1 { val.at(1) } else { val } + let off = 0.15 + let aL = if is-inf(vL) { (px - off, yL) } else { "v" + str(i) + "L" } + let aR = if is-inf(vR) { (px + off, yR) } else { "v" + str(i) + "R" } + node-anchors.push((aL, aR)) + } + } + + // Mũi tên + for i in range(n - 1) { + let s = node-anchors.at(i).at(1) + let e = node-anchors.at(i + 1).at(0) + if s != none and e != none { + line(s, e, mark: (end: ">", fill: __clr), stroke: 0.8pt + __clr) + } + } + }) +} +// Chuyên dùng cho các bài toán tối ưu (1 cực trị trên đoạn) +// ========================================== +#let bbt-opt( + var: $x$, + der: $y'$, + func: $y$, + x-vals: ($0$, $x_0$, $oo$), + d-signs: ($-$, $0$, $+$), + v-vals: ($oo$, $0$, $oo$), + is-min: true, // true: cực tiểu (\/), false: cực đại (/\) + w1: 1.5, // Chiều rộng cột nhãn + w2: 7, // Chiều rộng cột nội dung +) = context { + let __clr = text.fill + canvas(length: 1cm, { + import draw: * + + let h1 = 0.8 + let h2 = 0.8 + let h3 = 2.2 + let tw = w1 + w2 + let th = h1 + h2 + h3 + + // Kẻ khung và các đường ngang, dọc + rect((0, 0), (tw, -th), stroke: 1pt + __clr) + line((0, -h1), (tw, -h1), stroke: 1pt + __clr) + line((0, -h1 - h2), (tw, -h1 - h2), stroke: 1pt + __clr) + line((w1, 0), (w1, -th), stroke: 1pt + __clr) + + // Nhãn các hàng + content((w1 / 2, -h1 / 2), var) + content((w1 / 2, -h1 - h2 / 2), der) + content((w1 / 2, -h1 - h2 - h3 / 2), func) + + // Tọa độ x cho các cột nội dung + let x1 = w1 + 0.6 + let x2 = w1 + w2 / 2 + let x3 = tw - 0.6 + + // Hàng 1: x + content((x1, -h1 / 2), x-vals.at(0)) + content((x2, -h1 / 2), x-vals.at(1)) + content((x3, -h1 / 2), x-vals.at(2)) + + // Hàng 2: Đạo hàm + content(((x1 + x2) / 2, -h1 - h2 / 2), d-signs.at(0)) + content((x2, -h1 - h2 / 2), d-signs.at(1)) + content(((x2 + x3) / 2, -h1 - h2 / 2), d-signs.at(2)) + + // Hàng 3: Biến thiên + let y-top = -h1 - h2 - 0.5 + let y-bot = -th + 0.4 + + let (y1, y2, y3) = if is-min { + (y-top, y-bot, y-top) + } else { + (y-bot, y-top, y-bot) + } + + content((x1, y1), v-vals.at(0), name: "v1", padding: 0.1) + content((x2, y2), v-vals.at(1), name: "v2", padding: 0.1) + content((x3, y3), v-vals.at(2), name: "v3", padding: 0.1) + + // Vẽ mũi tên biến thiên + line("v1", "v2", mark: (end: ">", fill: __clr), stroke: 0.8pt + __clr) + line("v2", "v3", mark: (end: ">", fill: __clr), stroke: 0.8pt + __clr) + }) +} + +// ═══════════════════════════════════════════════════════════ +// BẢNG GIÁ TRỊ — Table đơn giản (không mũi tên) +// items: ((nhãn, giá trị), ...) → hiển thị dạng bảng 2 dòng +// Dùng cho: bảng giá trị hàm số, bảng xác suất, bảng tần số +// ═══════════════════════════════════════════════════════════ +#let bang-gia-tri( + labels: (), // hàng tiêu đề cột + rows: (), // mảng các hàng, mỗi hàng là tuple giá trị + accent: rgb("#0057b8"), + scale: 1cm, +) = context { + let __clr = text.fill + canvas(length: scale, { + import draw: * + let ncols = labels.len() + let nrows = rows.len() + 1 + let cw = 2.2 + let rh = 1.0 + let tw = ncols * cw + let th = nrows * rh + rect((0, 0), (tw, -th), stroke: 0.8pt + __clr ) + for i in range(1, ncols) { + line((i * cw, 0), (i * cw, -th), stroke: 0.5pt + luma(180)) + } + for j in range(1, nrows) { + line((0, -j * rh), (tw, -j * rh), stroke: 0.5pt + luma(180)) + } + for (i, lbl) in labels.enumerate() { + content((i * cw + cw / 2, -rh / 2), text(weight: "bold")[#lbl]) + } + for (r, row) in rows.enumerate() { + for (c, val) in row.enumerate() { + content((c * cw + cw / 2, -(r + 1) * rh - rh / 2), val) + } + } + }) +} + +// ═══════════════════════════════════════════════════════════ +// BẢNG PHÂN PHỐI — Dạng table 2 cột: X | P +// Dùng cho bảng phân phối xác suất, tần suất +// ═══════════════════════════════════════════════════════════ +#let bang-phan-phoi( + header: ($X$, $P$), + items: (), // ((x1, p1), (x2, p2), ...) + accent: rgb("#0057b8"), + scale: 1cm, +) = context { + let __clr = text.fill + canvas(length: scale, { + import draw: * + let n = items.len() + let cw = 2.4 + let rh = 0.9 + let tw = 2 * cw + let th = (n + 1) * rh + rect((0, 0), (tw, -th), stroke: 0.8pt + __clr ) + line((cw, 0), (cw, -th), stroke: 0.5pt + luma(180)) + for j in range(1, n + 2) { + line((0, -j * rh), (tw, -j * rh), stroke: 0.5pt + luma(180)) + } + content((cw / 2, -rh / 2), text(weight: "bold")[#header.at(0)]) + content((cw + cw / 2, -rh / 2), text(weight: "bold")[#header.at(1)]) + for (i, item) in items.enumerate() { + content((cw / 2, -(i + 1) * rh - rh / 2), item.at(0)) + content((cw + cw / 2, -(i + 1) * rh - rh / 2), item.at(1)) + } + }) +} + +// ═══════════════════════════════════════════════════════════ +// auto-bbt — BBT tự động kiểu cũ (giữ nguyên để tương thích) +// ═══════════════════════════════════════════════════════════ +#let auto-bbt( + x: (), + y-phay: (), + y: (), +) = context { + let __clr = text.fill + align(center)[ + #canvas({ + import draw: * + + // Khởi tạo các thông số tự động + let n = x.len() + let col1 = 1.5 // Độ rộng cột tiêu đề + let step = 3.0 // Khoảng cách giữa các điểm x + let w = col1 + (n - 1) * step + let h = 4.0 + + // Vẽ khung và lưới cơ bản + rect((0, 0), (w, h), stroke: 0.75pt + __clr ) + line((0, 2), (w, 2), stroke: 0.75pt + __clr ) + line((0, 3), (w, 3), stroke: 0.75pt + __clr ) + line((col1, 0), (col1, h), stroke: 0.75pt + __clr ) + + // Cột tiêu đề + content((col1 / 2, 3.5), [$x$]) + content((col1 / 2, 2.5), [$f'(x)$]) + content((col1 / 2, 1.0), [$f(x)$]) + + // 1. Điền hàng x tự động + for i in range(n) { + let cx = col1 + i * step + content((cx, 3.5), x.at(i)) + } + + // 2. Điền hàng f'(x) tự động + // Mảng y-phay có độ dài 2n - 1 (gồm nghiệm và khoảng giữa) + for i in range(y-phay.len()) { + let cx = col1 + i * (step / 2) + content((cx, 2.5), y-phay.at(i)) + } + + // 3. Điền hàng f(x) và thuật toán tự động vẽ mũi tên + let y_top = 1.6 + let y_mid = 1.0 + let y_bot = 0.4 + + // Hàm phụ xác định cao độ: 1 (Trên), -1 (Dưới), 0 (Giữa) + let get-y-coord(pos) = { + if pos == 1 { y_top } else if pos == -1 { y_bot } else { y_mid } + } + + // Điền các giá trị cực trị / giới hạn + for i in range(n) { + let item = y.at(i) + let cx = col1 + i * step + let cy = get-y-coord(item.at(1)) + content((cx, cy), item.at(0)) + } + + // Vòng lặp tự động phóng mũi tên + let dx = 0.6 // Độ thu vào trục x (không đâm vào chữ) + let dy = 0.25 // Độ thu vào trục y + + for i in range(n - 1) { + let item1 = y.at(i) + let item2 = y.at(i + 1) + + let x_start = col1 + i * step + let y_start = get-y-coord(item1.at(1)) + + let x_end = col1 + (i + 1) * step + let y_end = get-y-coord(item2.at(1)) + + // Thuật toán xét mũi tên đi lên hay đi xuống + let x1 = x_start + dx + let x2 = x_end - dx + + if y_start > y_end { + // Nghịch biến (Đi xuống) + line((x1, y_start - dy), (x2, y_end + dy), mark: (end: "stealth", fill: __clr)) + } else if y_start < y_end { + // Đồng biến (Đi lên) + line((x1, y_start + dy), (x2, y_end - dy), mark: (end: "stealth", fill: __clr)) + } else { + // Hàm hằng (Đi ngang) + line((x1 + 0.2, y_start), (x2 - 0.2, y_end), mark: (end: "stealth", fill: __clr)) + } + } + }) + ] +} diff --git a/packages/preview/sang-math/1.0.0/book-templates.typ b/packages/preview/sang-math/1.0.0/book-templates.typ new file mode 100644 index 0000000000..8917071e92 --- /dev/null +++ b/packages/preview/sang-math/1.0.0/book-templates.typ @@ -0,0 +1,761 @@ +// ================================================================ +// SANG-MATH BOOK TEMPLATES v1.0.0 +// Bộ giao diện sách / SGK / chuyên đề / workbook dùng chung. +// ================================================================ + +#let _book-palettes = ( + sgk-modern: ( + accent: rgb("#2563eb"), + accent-2: rgb("#0f766e"), + cover: rgb("#eff6ff"), + ink: rgb("#172033"), + soft: rgb("#dbeafe"), + name: "SGK Modern", + look: "clean", + ), + vdc-elite: ( + accent: rgb("#7e22ce"), + accent-2: rgb("#be185d"), + cover: rgb("#faf5ff"), + ink: rgb("#1f1b2e"), + soft: rgb("#ede9fe"), + name: "VDC Elite", + look: "elite", + ), + workbook-jade: ( + accent: rgb("#059669"), + accent-2: rgb("#0e7490"), + cover: rgb("#ecfdf5"), + ink: rgb("#12332b"), + soft: rgb("#d1fae5"), + name: "Workbook Jade", + look: "workbook", + ), + solution-crimson: ( + accent: rgb("#be123c"), + accent-2: rgb("#ea580c"), + cover: rgb("#fff1f2"), + ink: rgb("#34151d"), + soft: rgb("#ffe4e6"), + name: "Solution Crimson", + look: "solution", + ), + lesson-amber: ( + accent: rgb("#b45309"), + accent-2: rgb("#2563eb"), + cover: rgb("#fffbeb"), + ink: rgb("#302113"), + soft: rgb("#fef3c7"), + name: "Lesson Amber", + look: "lesson", + ), + olympiad-indigo: ( + accent: rgb("#4338ca"), + accent-2: rgb("#0f766e"), + cover: rgb("#eef2ff"), + ink: rgb("#15172f"), + soft: rgb("#e0e7ff"), + name: "Olympiad Indigo", + look: "notes", + ), + magazine-coral: ( + accent: rgb("#ea580c"), + accent-2: rgb("#0e7490"), + cover: rgb("#fff7ed"), + ink: rgb("#2d1b12"), + soft: rgb("#fed7aa"), + name: "Magazine Coral", + look: "magazine", + ), + blackboard-green: ( + accent: rgb("#16a34a"), + accent-2: rgb("#f59e0b"), + cover: rgb("#052e24"), + ink: rgb("#10251c"), + soft: rgb("#dcfce7"), + name: "Blackboard Green", + look: "blackboard", + ), + minimal-graphite: ( + accent: rgb("#334155"), + accent-2: rgb("#64748b"), + cover: rgb("#f8fafc"), + ink: rgb("#111827"), + soft: rgb("#e2e8f0"), + name: "Minimal Graphite", + look: "minimal", + ), + handout-sky: ( + accent: rgb("#0284c7"), + accent-2: rgb("#0f766e"), + cover: rgb("#f0f9ff"), + ink: rgb("#123044"), + soft: rgb("#e0f2fe"), + name: "Handout Sky", + look: "handout", + ), + research-slate: ( + accent: rgb("#475569"), + accent-2: rgb("#7c3aed"), + cover: rgb("#f1f5f9"), + ink: rgb("#0f172a"), + soft: rgb("#e2e8f0"), + name: "Research Slate", + look: "research", + ), + lotus-study: ( + accent: rgb("#be185d"), + accent-2: rgb("#059669"), + cover: rgb("#fdf2f8"), + ink: rgb("#311827"), + soft: rgb("#fce7f3"), + name: "Lotus Study", + look: "lotus", + ), +) + +#let book-palette(theme) = { + _book-palettes.at(theme, default: _book-palettes.sgk-modern) +} + +#let _smallcaps(body, fill: luma(80)) = text(size: 8.5pt, weight: "bold", fill: fill)[#upper(body)] + +#let _rule(accent) = line(length: 100%, stroke: 0.8pt + accent.lighten(45%)) + +#let _meta-pill(body, accent, fill: white, dark: false) = box( + fill: if dark { accent.transparentize(72%) } else { fill }, + stroke: 0.55pt + accent.lighten(if dark { 10% } else { 48% }), + inset: (x: 10pt, y: 5pt), + radius: 999pt, +)[#text(size: 8.5pt, weight: "bold", fill: if dark { white } else { accent.darken(8%) })[#body]] + +#let _cover( + title, + palette, + subtitle: none, + author: none, + institution: none, + grade: none, + subject: none, + year: none, + note: none, +) = { + pagebreak(weak: true) + let look = palette.look + if look == "clean" { + align(center + horizon)[ + #block(width: 100%, height: 100%, fill: white, inset: 0pt)[ + #block(width: 100%, height: 28%, fill: palette.cover, inset: 34pt)[ + #grid(columns: (1fr, auto), align: (left + top, right + top), + [ + #_smallcaps([SANG-MATH TEXTBOOK SERIES], fill: palette.accent) + #v(8pt) + #text(size: 11pt, fill: palette.ink.lighten(25%))[#institution] + ], + stack(dir: ltr, spacing: 6pt, + _meta-pill(subject, palette.accent), + _meta-pill(grade, palette.accent-2), + ), + ) + ] + #block(width: 100%, inset: (x: 34pt, y: 0pt))[ + #v(-22pt) + #block(width: 74%, fill: white, stroke: (top: 4pt + palette.accent, rest: 0.8pt + palette.accent.lighten(58%)), inset: 20pt, radius: 6pt)[ + #text(size: 37pt, weight: "bold", fill: palette.ink)[#title] + #if subtitle != none [ + #v(8pt) + #text(size: 14pt, fill: palette.accent.darken(8%))[#subtitle] + ] + ] + #v(22pt) + #grid(columns: (62%, 1fr), gutter: 18pt, + [ + #if note != none [ + #block(fill: palette.soft.lighten(55%), stroke: (left: 4pt + palette.accent), inset: 13pt, radius: 4pt)[ + #text(size: 10pt, fill: palette.ink)[#note] + ] + ] + ], + block(fill: palette.ink, inset: 16pt, radius: 6pt)[ + #text(size: 8.5pt, weight: "bold", fill: palette.accent.lighten(45%))[TÁC GIẢ] + #v(4pt) + #text(size: 13pt, weight: "bold", fill: white)[#author] + #v(12pt) + #text(size: 8.5pt, weight: "bold", fill: palette.accent.lighten(45%))[NĂM HỌC] + #v(4pt) + #text(size: 12pt, fill: white)[#year] + ], + ) + ] + #v(1fr) + #block(width: 100%, height: 18pt, fill: palette.accent)[] + #block(width: 100%, height: 10pt, fill: palette.accent-2)[] + ] + ] + } else if look == "elite" { + align(center + horizon)[ + #block(width: 100%, height: 100%, fill: palette.ink, inset: 34pt, radius: 0pt)[ + #v(12pt) + #grid(columns: (1fr, auto), align: (left, right), + _smallcaps([SANG-MATH ELITE SERIES], fill: palette.accent.lighten(35%)), + box(fill: palette.accent, inset: (x: 10pt, y: 5pt), radius: 999pt)[#text(fill: white, size: 8pt, weight: "bold")[VDC]] + ) + #v(56pt) + #block(width: 82%, stroke: (left: 5pt + palette.accent), inset: (left: 18pt, y: 4pt))[ + #text(size: 38pt, weight: "bold", fill: white)[#title] + #if subtitle != none [#v(8pt)#text(size: 14pt, fill: palette.accent.lighten(45%))[#subtitle]] + ] + #v(26pt) + #grid(columns: (1fr, 1fr), gutter: 10pt, + box(fill: palette.accent.transparentize(76%), inset: 12pt, radius: 6pt)[#text(fill: white)[#subject · #grade]], + box(fill: palette.accent-2.transparentize(74%), inset: 12pt, radius: 6pt)[#text(fill: white)[#author · #year]], + ) + #v(1fr) + #if note != none [#block(width: 100%, fill: palette.accent, inset: 14pt, radius: 8pt)[#text(fill: white, size: 10pt)[#note]]] + #v(16pt) + #text(size: 9pt, fill: white.transparentize(32%))[#institution] + ] + ] + } else if look == "workbook" { + align(center + horizon)[ + #block(width: 100%, height: 100%, fill: white, inset: 28pt, stroke: 1pt + palette.accent.lighten(50%))[ + #block(width: 100%, fill: palette.cover, inset: 18pt, radius: 10pt)[ + #_smallcaps([WORKBOOK · HỌC SINH], fill: palette.accent) + #v(22pt) + #text(size: 34pt, weight: "bold", fill: palette.accent)[#title] + #if subtitle != none [#v(6pt)#text(size: 13pt, fill: palette.ink)[#subtitle]] + ] + #v(28pt) + #for i in range(8) { + line(length: 100%, stroke: 0.55pt + palette.accent.lighten(62%)) + v(18pt) + } + #v(1fr) + #grid(columns: (1fr, 1fr), gutter: 12pt, + box(stroke: 0.7pt + palette.accent.lighten(45%), inset: 12pt, radius: 6pt)[Tên học sinh:#v(18pt)], + box(stroke: 0.7pt + palette.accent.lighten(45%), inset: 12pt, radius: 6pt)[Lớp:#v(18pt)], + ) + #v(14pt) + #text(size: 9pt, fill: palette.ink.lighten(38%))[#institution · #year] + ] + ] + } else if look == "lesson" { + align(center + horizon)[ + #block(width: 100%, height: 100%, fill: palette.cover, inset: 30pt)[ + #grid(columns: (36%, 1fr), gutter: 22pt, + block(fill: palette.accent, height: 100%, inset: 18pt, radius: 8pt)[ + #text(fill: white, size: 11pt, weight: "bold")[GIÁO ÁN] + #v(1fr) + #text(fill: white, size: 20pt, weight: "bold")[#subject] + #text(fill: white.transparentize(20%), size: 11pt)[#grade · #year] + ], + [ + #v(34pt) + #text(size: 34pt, weight: "bold", fill: palette.ink)[#title] + #if subtitle != none [#v(8pt)#text(size: 14pt, fill: palette.accent)[#subtitle]] + #v(28pt) + #block(fill: white, stroke: 0.8pt + palette.accent.lighten(48%), inset: 14pt, radius: 8pt)[ + #text(weight: "bold", fill: palette.accent)[#author] + #linebreak() + #text(size: 9.5pt)[#institution] + ] + ], + ) + ] + ] + } else if look == "magazine" { + align(center + horizon)[ + #block(width: 100%, height: 100%, fill: white, inset: 0pt)[ + #block(width: 100%, height: 34%, fill: palette.accent, inset: 28pt)[ + #text(fill: white, size: 11pt, weight: "bold")[SANG-MATH MAGAZINE] + #v(1fr) + #text(fill: white, size: 30pt, weight: "bold")[#title] + ] + #block(width: 100%, inset: 28pt)[ + #if subtitle != none [#text(size: 16pt, fill: palette.accent)[#subtitle]] + #v(24pt) + #grid(columns: (2fr, 1fr), gutter: 18pt, + block(fill: palette.cover, inset: 18pt, radius: 10pt)[#text(size: 11pt)[#if note != none [#note] else [Tài liệu học tập theo phong cách tạp chí: nhiều điểm nhấn, dễ đọc, hợp chuyên đề ngắn.]]], + block(fill: palette.ink, inset: 16pt, radius: 10pt)[#text(fill: white, weight: "bold")[#subject #linebreak()#grade #linebreak()#year]], + ) + #v(1fr) + #text(size: 9pt, fill: palette.ink.lighten(40%))[#author · #institution] + ] + ] + ] + } else if look == "blackboard" { + align(center + horizon)[ + #block(width: 100%, height: 100%, fill: palette.cover, inset: 32pt)[ + #block(width: 100%, height: 100%, stroke: 1.2pt + white.transparentize(35%), inset: 24pt, radius: 6pt)[ + #text(fill: palette.accent-2, size: 10pt, weight: "bold")[BLACKBOARD NOTES] + #v(55pt) + #text(fill: white, size: 34pt, weight: "bold")[#title] + #if subtitle != none [#v(10pt)#text(fill: white.transparentize(20%), size: 15pt)[#subtitle]] + #v(1fr) + #text(fill: white.transparentize(18%))[#author · #subject · #grade] + ] + ] + ] + } else if look == "minimal" or look == "research" { + align(center + horizon)[ + #block(width: 100%, height: 100%, fill: white, inset: 36pt)[ + #v(24pt) + #line(length: 100%, stroke: 1.4pt + palette.accent) + #v(34pt) + #text(size: 33pt, weight: "bold", fill: palette.ink)[#title] + #if subtitle != none [#v(10pt)#text(size: 13pt, fill: palette.accent)[#subtitle]] + #v(36pt) + #block(width: 54%, inset: (x: 0pt, y: 10pt), stroke: (top: 0.7pt + palette.accent.lighten(45%), bottom: 0.7pt + palette.accent.lighten(45%)))[ + #text(size: 10pt)[#author #linebreak()#institution #linebreak()#subject · #grade · #year] + ] + #v(1fr) + #if note != none [#text(size: 9pt, fill: palette.ink.lighten(45%))[#note]] + ] + ] + } else if look == "handout" or look == "lotus" { + align(center + horizon)[ + #block(width: 100%, height: 100%, fill: palette.cover, inset: 30pt)[ + #grid(columns: (1fr, 120pt), gutter: 20pt, + [ + #_smallcaps([HANDOUT · SANG-MATH], fill: palette.accent) + #v(34pt) + #text(size: 35pt, weight: "bold", fill: palette.ink)[#title] + #if subtitle != none [#v(8pt)#text(size: 14pt, fill: palette.accent)[#subtitle]] + ], + block(fill: palette.accent, height: 100%, radius: 12pt, inset: 14pt)[ + #text(fill: white, size: 20pt, weight: "bold")[#subject] + #v(1fr) + #text(fill: white.transparentize(18%), size: 10pt)[#grade #linebreak()#year] + ], + ) + #v(1fr) + #block(fill: white, stroke: 0.7pt + palette.accent.lighten(50%), inset: 12pt, radius: 8pt)[ + #text(weight: "bold", fill: palette.accent)[#author] + #linebreak() + #text(size: 9pt)[#institution] + ] + ] + ] + } else { + // Premium generic cover with geometric background + align(center + horizon)[ + #block(width: 100%, height: 100%, fill: palette.cover, clip: true, inset: 34pt, radius: 0pt)[ + // Geometric Background elements + #place(top + right, dx: 34pt, dy: -34pt)[ + #circle(radius: 120pt, fill: palette.accent.transparentize(85%)) + ] + #place(bottom + left, dx: -80pt, dy: 80pt)[ + #circle(radius: 200pt, fill: palette.accent-2.transparentize(90%)) + ] + #place(top + left, dx: -34pt, dy: 150pt)[ + #polygon( + fill: palette.accent.transparentize(92%), + (0pt, 0pt), (250pt, 0pt), (150pt, 300pt), (-100pt, 250pt) + ) + ] + + // Content + #v(20pt) + #align(left)[#_smallcaps([SANG-MATH PREMIUM TEMPLATE], fill: palette.accent)] + #v(42pt) + #align(left)[ + #text(size: if look == "notes" { 32pt } else { 42pt }, weight: "bold", fill: palette.ink)[#title] + #if subtitle != none [ + #v(12pt) + #text(size: 16pt, fill: palette.accent.darken(10%))[#subtitle] + ] + ] + #v(28pt) + #block(width: if look == "notes" { 100% } else { 85% }, fill: white.transparentize(15%), stroke: 1.5pt + palette.accent.lighten(55%), inset: 20pt, radius: 12pt)[ + #grid(columns: (1fr, 1fr), gutter: 14pt, + [#_smallcaps([Môn học], fill: palette.accent)#linebreak()#text(weight: "bold", size: 13pt)[#subject]], + [#_smallcaps([Khối lớp], fill: palette.accent)#linebreak()#text(weight: "bold", size: 13pt)[#grade]], + [#_smallcaps([Tác giả], fill: palette.accent)#linebreak()#text(weight: "bold", size: 13pt)[#author]], + [#_smallcaps([Năm học], fill: palette.accent)#linebreak()#text(weight: "bold", size: 13pt)[#year]], + ) + ] + #v(1fr) + #if note != none [ + #block(width: 100%, fill: palette.accent, inset: 16pt, radius: 8pt)[ + #text(fill: white, size: 11pt)[#note] + ] + ] + #v(24pt) + #align(left)[#text(size: 10pt, fill: palette.ink.lighten(20%), weight: "bold")[#institution]] + ] + ] + } + pagebreak() +} + +#let book-theme( + body, + theme: "sgk-modern", + title: "TÀI LIỆU TOÁN", + subtitle: none, + author: "GV Nguyễn Văn Sang", + institution: "ConicTypst", + grade: "THPT", + subject: "Toán", + year: "2025-2026", + cover-note: none, + toc: true, + paper: "a4", + margin: (x: 20mm, y: 18mm), + body-font: "New Computer Modern", + heading-font: "New Computer Modern", +) = { + let p = book-palette(theme) + set page(paper: paper, margin: margin, numbering: none, fill: white) + set text(font: body-font, size: 11pt, fill: p.ink) + set par(leading: 0.65em, first-line-indent: 0pt, justify: true) + show strong: it => text(fill: p.accent, weight: "bold")[#it.body] + show heading: it => { + if it.level == 1 { + text(font: heading-font, size: 22pt, weight: "bold", fill: p.accent)[#it.body] + } else if it.level == 2 { + text(font: heading-font, size: 16pt, weight: "bold", fill: p.accent.darken(5%))[#it.body] + } else { + text(font: heading-font, size: 12.5pt, weight: "bold", fill: p.accent-2)[#it.body] + } + } + + _cover( + title, + p, + subtitle: subtitle, + author: author, + institution: institution, + grade: grade, + subject: subject, + year: year, + note: cover-note, + ) + + if toc { + align(center)[#text(size: 22pt, weight: "bold", fill: p.accent)[Mục lục]] + v(8pt) + outline(title: none) + pagebreak() + } + + set page( + numbering: "1", + header: context { + let elems = query(selector(heading.where(level: 1)).before(here())) + let chapter-title = if elems.len() > 0 { elems.last().body } else { title } + let page-num = counter(page).get().first() + if calc.rem(page-num, 2) == 0 { + text(fill: p.accent.lighten(20%), size: 9pt, weight: "bold")[#chapter-title] + v(-4pt); line(length: 100%, stroke: 0.5pt + p.accent.lighten(50%)) + } else { + align(right)[#text(fill: p.accent.lighten(20%), size: 9pt, weight: "bold")[#chapter-title]] + v(-4pt); line(length: 100%, stroke: 0.5pt + p.accent.lighten(50%)) + } + }, + footer: context { + let page-num = counter(page).get().first() + if calc.rem(page-num, 2) == 0 { + line(length: 100%, stroke: 0.5pt + p.accent.lighten(50%)); v(4pt) + text(fill: p.accent, weight: "bold")[#page-num] + h(1fr) + text(fill: p.ink.lighten(40%), size: 9pt)[#institution] + } else { + line(length: 100%, stroke: 0.5pt + p.accent.lighten(50%)); v(4pt) + text(fill: p.ink.lighten(40%), size: 9pt)[#title] + h(1fr) + text(fill: p.accent, weight: "bold")[#page-num] + } + } + ) + counter(page).update(1) + body +} + +#let _hidden-heading(level, title) = hide(heading(level: level, outlined: true)[#title]) + +#let book-chapter(title, number: none, kicker: [Chương], theme: "sgk-modern") = { + let p = book-palette(theme) + _hidden-heading(1, title) + if p.look == "clean" { + block(width: 100%, inset: (x: 0pt, y: 6pt), stroke: (top: 1.2pt + p.accent.lighten(35%), bottom: 1.2pt + p.accent.lighten(55%)))[ + #grid(columns: (72pt, 1fr), gutter: 16pt, align: (center + horizon, left + horizon), + block(width: 58pt, height: 58pt, fill: p.accent, radius: 4pt)[ + #align(center + horizon)[ + #text(size: 8pt, weight: "bold", fill: white.transparentize(12%))[#upper(kicker)] + #linebreak() + #text(size: 22pt, weight: "bold", fill: white)[#if number == none [--] else [#number]] + ] + ], + [ + #text(size: 9pt, weight: "bold", fill: p.accent-2)[CHỦ ĐỀ TRỌNG TÂM] + #v(3pt) + #text(size: 25pt, weight: "bold", fill: p.ink)[#title] + ], + ) + ] + } else if p.look == "elite" { + block(width: 100%, fill: p.ink, inset: 18pt, radius: 0pt, stroke: (bottom: 3pt + p.accent))[ + #text(size: 9pt, weight: "bold", fill: p.accent.lighten(35%))[#upper(kicker) #if number != none [#number]] + #v(5pt) + #text(size: 25pt, weight: "bold", fill: white)[#title] + ] + } else if p.look == "workbook" { + grid(columns: (auto, 1fr), gutter: 12pt, align: (left + horizon, left + horizon), + box(width: 42pt, height: 42pt, fill: p.accent, radius: 999pt)[#align(center + horizon)[#text(fill: white, weight: "bold")[#if number == none [#kicker] else [#number]]]], + block(width: 100%, fill: p.cover, stroke: (bottom: 1pt + p.accent.lighten(45%)), inset: 12pt)[ + #text(size: 22pt, weight: "bold", fill: p.accent)[#title] + ], + ) + } else if p.look == "lesson" { + block(width: 100%, fill: p.cover, stroke: 0.9pt + p.accent.lighten(45%), inset: 14pt, radius: 8pt)[ + #grid(columns: (1fr, auto), align: (left + horizon, right + horizon), + text(size: 21pt, weight: "bold", fill: p.ink)[#title], + box(fill: p.accent, inset: (x: 12pt, y: 7pt), radius: 6pt)[#text(fill: white, weight: "bold")[#kicker #if number != none [#number]]], + ) + ] + } else if p.look == "notes" { + block(width: 100%, inset: (x: 0pt, y: 10pt), stroke: (top: 1.2pt + p.accent, bottom: 1.2pt + p.accent))[ + #text(size: 10pt, fill: p.accent, weight: "bold")[#kicker #if number != none [#number]] + #h(1em) + #text(size: 23pt, weight: "bold", fill: p.ink)[#title] + ] + } else if p.look == "magazine" { + grid(columns: (1fr, 72pt), gutter: 14pt, + block(fill: p.cover, inset: 16pt, radius: 10pt)[#text(size: 24pt, weight: "bold", fill: p.accent)[#title]], + block(fill: p.accent, inset: 12pt, radius: 10pt)[#align(center + horizon)[#text(fill: white, size: 18pt, weight: "bold")[#if number == none [#kicker] else [#number]]]], + ) + } else if p.look == "blackboard" { + block(width: 100%, fill: p.cover, inset: 16pt, radius: 6pt)[ + #text(size: 10pt, fill: p.accent-2, weight: "bold")[#kicker #if number != none [#number]] + #v(5pt) + #text(size: 24pt, weight: "bold", fill: white)[#title] + ] + } else if p.look == "minimal" or p.look == "research" { + block(width: 100%, inset: (x: 0pt, y: 8pt), stroke: (left: 5pt + p.accent))[ + #h(10pt)#text(size: 24pt, weight: "bold", fill: p.ink)[#if number != none [#number. ]#title] + ] + } else { + // Default / "sgk-modern" / Premium ribbon look + block(width: 100%, breakable: false)[ + #place(top + left, dx: 0pt, dy: -5pt)[ + #block(fill: p.accent.darken(15%), width: 100%, height: 20pt) + ] + #block(width: 100%, fill: p.accent, stroke: (bottom: 4pt + p.accent-2), inset: 18pt, radius: (top-right: 12pt, bottom-right: 12pt, top-left: 2pt, bottom-left: 2pt))[ + #grid(columns: (auto, 1fr), gutter: 16pt, align: (left + horizon, left + horizon), + box(fill: white, inset: (x: 14pt, y: 10pt), radius: 7pt, stroke: 1.5pt + p.accent-2)[ + #text(weight: "bold", fill: p.accent, size: 16pt)[#kicker #if number != none [ #number]] + ], + [ + #text(size: 24pt, weight: "bold", fill: white)[#title] + #v(4pt) + #line(length: 100%, stroke: 0.8pt + white.transparentize(35%)) + ], + ) + ] + ] + } + v(14pt) +} + +#let book-lesson(title, number: none, theme: "sgk-modern") = { + let p = book-palette(theme) + _hidden-heading(2, title) + if p.look == "clean" { + block(width: 100%, fill: p.cover.lighten(35%), stroke: (left: 5pt + p.accent, rest: 0.55pt + p.accent.lighten(58%)), inset: 13pt, radius: 5pt)[ + #grid(columns: (1fr, auto), align: (left + horizon, right + horizon), + text(size: 16pt, weight: "bold", fill: p.ink)[#title], + _meta-pill(if number == none { [Bài] } else { [Bài #number] }, p.accent, fill: white), + ) + ] + } else if p.look == "workbook" { + block(width: 100%, fill: white, stroke: 0.7pt + p.accent.lighten(45%), inset: 12pt, radius: 4pt)[ + #text(size: 15pt, weight: "bold", fill: p.accent)[Phiếu #if number != none [#number · ]#title] + #v(5pt) + #for i in range(2) { line(length: 100%, stroke: 0.45pt + p.accent.lighten(65%)); v(10pt) } + ] + } else if p.look == "solution" { + block(width: 100%, fill: p.accent, inset: 12pt, radius: 999pt)[ + #text(size: 15pt, weight: "bold", fill: white)[Lời giải #if number != none [#number · ]#title] + ] + } else if p.look == "lesson" { + grid(columns: (auto, 1fr), gutter: 10pt, + box(fill: p.accent, inset: 10pt, radius: 6pt)[#text(fill: white, weight: "bold")[Tiết #if number != none [#number] else [--]]], + block(fill: p.soft, inset: 12pt, radius: 6pt)[#text(size: 15pt, weight: "bold", fill: p.ink)[#title]], + ) + } else if p.look == "magazine" or p.look == "handout" or p.look == "lotus" { + block(width: 100%, fill: p.accent.lighten(92%), inset: 12pt, radius: 12pt)[ + #text(size: 15pt, weight: "bold", fill: p.accent.darken(8%))[#if number != none [Bài #number · ]#title] + ] + } else if p.look == "blackboard" { + block(width: 100%, fill: p.cover, stroke: 0.7pt + p.accent.lighten(20%), inset: 12pt, radius: 5pt)[ + #text(size: 15pt, weight: "bold", fill: white)[#if number != none [Bài #number · ]#title] + ] + } else { + // Premium generic block for lesson + block(width: 100%, fill: p.soft, stroke: (left: 5pt + p.accent), inset: 13pt, radius: (top-right: 7pt, bottom-right: 7pt))[ + #text(size: 16pt, weight: "bold", fill: p.accent)[Bài #if number != none [#number. ]#title] + ] + } + v(10pt) +} + +#let book-section(title, number: none, theme: "sgk-modern") = { + let p = book-palette(theme) + _hidden-heading(3, title) + if p.look == "clean" { + grid(columns: (auto, 1fr), gutter: 8pt, align: (left + horizon, left + horizon), + box(width: 5pt, height: 22pt, fill: p.accent, radius: 999pt)[], + [ + #text(size: 13.5pt, weight: "bold", fill: p.ink)[#if number != none [#number. ]#title] + #v(2pt) + #line(length: 52%, stroke: 0.65pt + p.accent.lighten(45%)) + ], + ) + } else if p.look == "notes" { + block(width: 100%, inset: (x: 0pt, y: 5pt), stroke: (bottom: 0.7pt + p.accent.lighten(42%)))[ + #text(size: 12.5pt, weight: "bold", fill: p.ink)[#if number != none [#number. ]#title] + ] + } else if p.look == "blackboard" { + block(width: 100%, inset: (x: 0pt, y: 6pt), stroke: (bottom: 0.8pt + p.accent))[ + #text(size: 12.5pt, weight: "bold", fill: p.accent.darken(5%))[#if number != none [#number. ]#title] + ] + } else { + // Premium section tag + grid(columns: (auto, 1fr), gutter: 9pt, align: (left + horizon, left + horizon), + box(fill: p.accent, inset: (x: 10pt, y: 5pt), radius: (top-left: 6pt, bottom-right: 6pt))[ + #text(fill: white, weight: "bold", size: 10pt)[#if number == none [Mục] else [#number]] + ], + [ + #text(size: 13.5pt, weight: "bold", fill: p.accent.darken(8%))[#title] + #v(2pt) + #_rule(p.accent) + ], + ) + } + v(8pt) +} + +#let _ped-box( + body, + title: [Ghi chú], + icon: [!], + accent: rgb("#2563eb"), + fill: auto, + stroke: auto, + radius: 8pt, + inset: 12pt, + boxed-title: true, + style: "card", +) = { + let real-fill = if fill == auto { accent.lighten(95%) } else { fill } + let real-stroke = if stroke == auto { 0.75pt + accent.lighten(45%) } else { stroke } + + if style == "dark" { + block(width: 100%, breakable: true, fill: accent.darken(50%), stroke: 1pt + accent.lighten(20%), inset: 0pt, radius: 6pt, clip: true)[ + #block(width: 100%, fill: accent.darken(20%), inset: (x: 12pt, y: 8pt))[ + #grid(columns: (auto, 1fr), gutter: 8pt, align: horizon, + text(fill: white, weight: "bold", size: 12pt)[#icon], + text(weight: "bold", fill: white, size: 11.5pt)[#title] + ) + ] + #block(width: 100%, inset: inset)[ + #text(fill: white.transparentize(10%))[#body] + ] + ] + } else if style == "worksheet" { + block(width: 100%, breakable: true, fill: real-fill, stroke: (left: 4pt + accent, rest: 1pt + accent.lighten(50%)), radius: 4pt, inset: inset)[ + #place(top + right, dx: 4pt, dy: -24pt)[ + #box(fill: white, stroke: 1pt + accent, radius: 999pt, inset: (x: 12pt, y: 5pt))[ + #text(weight: "bold", fill: accent)[#icon #h(2pt) #title] + ] + ] + #v(2pt) + #body + #v(4pt) + #for i in range(2) { line(length: 100%, stroke: 0.4pt + accent.lighten(68%)); v(8pt) } + ] + } else if style == "solution" { + block(width: 100%, breakable: true, fill: real-fill, stroke: (left: 3pt + accent, top: 0.5pt + accent.lighten(60%), right: 0.5pt + accent.lighten(60%), bottom: 0.5pt + accent.lighten(60%)), inset: (x: 14pt, y: 12pt), radius: 4pt)[ + #grid(columns: (auto, 1fr), align: (left + horizon, left + horizon), gutter: 8pt, + box(fill: accent, radius: 4pt, inset: (x: 8pt, y: 4pt))[#text(fill: white, weight: "bold")[#icon]], + text(weight: "bold", fill: accent.darken(10%), size: 12pt)[#title] + ) + #v(6pt) + #body + ] + } else if style == "rule" { + block(width: 100%, breakable: true, inset: (x: 0pt, y: 12pt), stroke: (top: 2pt + accent, bottom: 1pt + accent.lighten(50%)))[ + #text(weight: "bold", fill: accent, size: 12pt)[#icon #h(4pt) #title] + #v(6pt) + #body + ] + } else { + // Tcolorbox-like "card" style (used by sgk-modern, lesson, etc.) + block(width: 100%, breakable: true, stroke: 1.2pt + accent, radius: 6pt, clip: true, fill: real-fill, inset: 0pt)[ + #block(width: 100%, fill: accent, inset: (x: 12pt, y: 8pt), radius: 0pt, stroke: none)[ + #text(weight: "bold", fill: white, size: 11.5pt)[#icon #h(4pt) #title] + ] + #block(width: 100%, inset: inset)[#body] + ] + } + v(8pt) +} + +#let _box-style(theme) = { + let p = book-palette(theme) + if p.look == "elite" { "dark" } + else if p.look == "workbook" { "worksheet" } + else if p.look == "solution" { "solution" } + else if p.look == "notes" or p.look == "minimal" or p.look == "research" { "rule" } + else if p.look == "blackboard" { "dark" } + else if p.look == "handout" { "worksheet" } + else { "card" } +} + +#let goal-box(body, title: [Mục tiêu], theme: "sgk-modern") = _ped-box(body, title: title, icon: [MT], accent: book-palette(theme).accent, style: _box-style(theme)) +#let warmup-box(body, title: [Khởi động], theme: "sgk-modern") = _ped-box(body, title: title, icon: [KD], accent: rgb("#ea580c"), style: _box-style(theme)) +#let explore-box(body, title: [Khám phá], theme: "sgk-modern") = _ped-box(body, title: title, icon: [KP], accent: rgb("#0e7490"), style: _box-style(theme)) +#let theory-box(body, title: [Lý thuyết], theme: "sgk-modern") = _ped-box(body, title: title, icon: [LT], accent: book-palette(theme).accent, style: _box-style(theme)) +#let definition-box(body, title: [Định nghĩa], theme: "sgk-modern") = _ped-box(body, title: title, icon: [DN], accent: rgb("#7c3aed"), style: _box-style(theme)) +#let theorem-box(body, title: [Định lý], theme: "sgk-modern") = _ped-box(body, title: title, icon: [DL], accent: rgb("#be123c"), style: _box-style(theme)) +#let method-box(body, title: [Phương pháp], theme: "sgk-modern") = _ped-box(body, title: title, icon: [PP], accent: rgb("#0f766e"), style: _box-style(theme)) +#let example-box(body, title: [Ví dụ], theme: "sgk-modern") = _ped-box(body, title: title, icon: [VD], accent: rgb("#2563eb"), fill: rgb("#eff6ff"), style: _box-style(theme)) +#let activity-box(body, title: [Hoạt động], theme: "sgk-modern") = _ped-box(body, title: title, icon: [HD], accent: rgb("#b45309"), fill: rgb("#fffbeb"), style: _box-style(theme)) +#let practice-box(body, title: [Luyện tập], theme: "sgk-modern") = _ped-box(body, title: title, icon: [LT], accent: rgb("#059669"), fill: rgb("#ecfdf5"), style: _box-style(theme)) +#let warning-box(body, title: [Lưu ý], theme: "sgk-modern") = _ped-box(body, title: title, icon: [!], accent: rgb("#dc2626"), fill: rgb("#fef2f2"), style: _box-style(theme)) +#let summary-box(body, title: [Tóm tắt], theme: "sgk-modern") = _ped-box(body, title: title, icon: [TK], accent: rgb("#334155"), fill: rgb("#f8fafc"), style: _box-style(theme)) +#let exercise-box(body, title: [Bài tập], theme: "sgk-modern", lines: 0) = { + _ped-box( + [ + #body + #if lines > 0 [ + #v(6pt) + #for i in range(lines) { + line(length: 100%, stroke: 0.45pt + luma(185)) + v(9pt) + } + ] + ], + title: title, + icon: [BT], + accent: rgb("#4338ca"), + fill: rgb("#eef2ff"), + style: _box-style(theme), + ) +} + +#let book-template-names = ( + "sgk-modern", + "vdc-elite", + "workbook-jade", + "solution-crimson", + "lesson-amber", + "olympiad-indigo", + "magazine-coral", + "blackboard-green", + "minimal-graphite", + "handout-sky", + "research-slate", + "lotus-study", +) diff --git a/packages/preview/sang-math/1.0.0/core/colors.typ b/packages/preview/sang-math/1.0.0/core/colors.typ new file mode 100644 index 0000000000..2da3ea4215 --- /dev/null +++ b/packages/preview/sang-math/1.0.0/core/colors.typ @@ -0,0 +1,58 @@ +// ═══════════════════════════════════════════════════════════════ +// CORE / COLORS.TYP — Bảng màu chuẩn cho sang-math +// ═══════════════════════════════════════════════════════════════ +// Đặt tên màu theo ngữ nghĩa thay vì hardcode hex. +// Dùng nhất quán trong toàn bộ package. +// ─────────────────────────────────────────────────────────────── + +// ── Màu Hình Học Chuẩn ──────────────────────────────────────── +#let sm-blue = rgb("1976D2") // Điểm, đường chính +#let sm-blue-light = rgb("BBDEFB") // Tô màu mặt phẳng +#let sm-green = rgb("388E3C") // Điểm thứ hai, kết quả +#let sm-green-light= rgb("C8E6C9") // Tô màu vùng xanh +#let sm-red = rgb("E91E63") // Đường nổi bật, nghiệm +#let sm-red-light = rgb("FCE4EC") // Tô màu vùng đỏ +#let sm-orange = rgb("F57C00") // Đường phụ, điểm trung +#let sm-purple = rgb("7B1FA2") // Đường conic đặc biệt +#let sm-gray = rgb("757575") // Nét đứt, đường khuất +#let sm-gray-light = rgb("EEEEEE") // Nền, fill nhạt + +// ── Màu Khối 3D ─────────────────────────────────────────────── +#let sm-face-top = rgb("E3F2FD") // Mặt trên khối +#let sm-face-front = rgb("BBDEFB") // Mặt trước khối +#let sm-face-side = rgb("90CAF9") // Mặt bên khối +#let sm-face-dark = rgb("64B5F6") // Mặt tối/khuất + +// ── Màu Giải Tích ───────────────────────────────────────────── +#let sm-func-1 = rgb("1565C0") // Đường đồ thị hàm 1 +#let sm-func-2 = rgb("C62828") // Đường đồ thị hàm 2 +#let sm-func-3 = rgb("2E7D32") // Đường đồ thị hàm 3 +#let sm-tangent = rgb("FF8F00") // Tiếp tuyến +#let sm-area = rgb("E8F5E9") // Tô diện tích tích phân +#let sm-area-stroke= rgb("43A047") // Viền diện tích + +// ── Màu Trắc Nghiệm ─────────────────────────────────────────── +#let sm-correct = rgb("388E3C") // Đáp án đúng +#let sm-wrong = rgb("D32F2F") // Đáp án sai +#let sm-highlight = rgb("FFF9C4") // Highlight câu hỏi + +// ── Màu Xác Suất / Thống Kê ─────────────────────────────────── +#let sm-event-a = rgb("1565C0") // Biến cố A +#let sm-event-b = rgb("C62828") // Biến cố B +#let sm-event-ab = rgb("6A1B9A") // Giao AB +#let sm-tree-node = rgb("E3F2FD") // Nút sơ đồ cây + +// ── Shorthand Palette (dùng cho hàm nhận color param) ───────── +// Cho phép gọi: draw-helix(color: sm.red) +#let sm = ( + blue: sm-blue, + blue-light: sm-blue-light, + green: sm-green, + green-light: sm-green-light, + red: sm-red, + red-light: sm-red-light, + orange: sm-orange, + purple: sm-purple, + gray: sm-gray, + gray-light: sm-gray-light, +) diff --git a/packages/preview/sang-math/1.0.0/core/math-utils.typ b/packages/preview/sang-math/1.0.0/core/math-utils.typ new file mode 100644 index 0000000000..9bb9c2bc2d --- /dev/null +++ b/packages/preview/sang-math/1.0.0/core/math-utils.typ @@ -0,0 +1,135 @@ +// ═══════════════════════════════════════════════════════════════ +// CORE / MATH-UTILS.TYP — Hàm tính toán toán học phụ trợ +// ═══════════════════════════════════════════════════════════════ +// Không import gì — đây là tầng nền, không phụ thuộc module khác. +// Dùng trong: geometry-2d, geometry-3d, calculus, statistics +// ─────────────────────────────────────────────────────────────── + +// ── Danh sách hàm ────────────────────────────────────────────── +// linspace(start, stop, n) Mảng n điểm đều từ start→stop +// lerp(a, b, t) Nội suy tuyến tính +// clamp(x, lo, hi) Kẹp giá trị trong [lo, hi] +// rotate-2d(pt, angle) Xoay điểm 2D quanh gốc tọa độ +// vec-norm((dx, dy)) Chuẩn hóa vector 2D +// vec-scale((x, y), s) Nhân vector với scalar +// vec-add((ax, ay), (bx, by)) Cộng hai vector +// pt-lerp(A, B, t) Nội suy điểm giữa A và B +// sign(x) Dấu của x: -1, 0, +1 +// deg-to-rad(d) Đổi độ → radian (wrapper tiện) +// rad-to-deg(r) Đổi radian → độ +// ─────────────────────────────────────────────────────────────── + +/// Tạo mảng gồm `n` giá trị phân bố đều từ `start` đến `stop` (inclusive). +/// Tương đương numpy.linspace. +/// +/// - start (float): Giá trị đầu. +/// - stop (float): Giá trị cuối. +/// - n (int): Số phần tử. Phải >= 2. +/// → returns array of float +#let linspace(start, stop, n) = { + if n <= 1 { return (start,) } + let step = (stop - start) / (n - 1) + range(0, n).map(i => start + i * step) +} + +/// Nội suy tuyến tính giữa `a` và `b` theo tham số `t ∈ [0, 1]`. +/// lerp(a, b, 0) = a; lerp(a, b, 1) = b. +/// +/// - a (float): Giá trị đầu. +/// - b (float): Giá trị cuối. +/// - t (float): Tham số nội suy [0, 1]. +/// → returns float +#let lerp(a, b, t) = a + (b - a) * t + +/// Kẹp giá trị `x` vào khoảng `[lo, hi]`. +/// +/// - x (float): Giá trị cần kẹp. +/// - lo (float): Cận dưới. +/// - hi (float): Cận trên. +/// → returns float +#let clamp(x, lo, hi) = calc.max(lo, calc.min(hi, x)) + +/// Dấu của x: trả về -1 (âm), 0 (zero), hoặc 1 (dương). +/// +/// - x (float): Giá trị đầu vào. +/// → returns int +#let sign(x) = { + if x > 0 { 1 } else if x < 0 { -1 } else { 0 } +} + +/// Đổi đơn vị đo góc từ độ (degree) sang radian. +/// +/// - d (float): Góc tính bằng độ. +/// → returns float (radian) +#let deg-to-rad(d) = d * calc.pi / 180 + +/// Đổi đơn vị đo góc từ radian sang độ (degree). +/// +/// - r (float): Góc tính bằng radian. +/// → returns float (degree) +#let rad-to-deg(r) = r * 180 / calc.pi + +/// Xoay điểm `pt = (x, y)` quanh gốc tọa độ `(0, 0)` một góc `angle` (radian). +/// +/// - pt (array): Điểm cần xoay dạng `(x, y)`. +/// - angle (float): Góc xoay tính bằng radian (dương = ngược chiều kim đồng hồ). +/// → returns (float, float) +#let rotate-2d(pt, angle) = { + let (x, y) = pt + let cos-a = calc.cos(angle) + let sin-a = calc.sin(angle) + (x * cos-a - y * sin-a, x * sin-a + y * cos-a) +} + +/// Cộng hai vector 2D. +/// +/// - a (array): Vector thứ nhất `(ax, ay)`. +/// - b (array): Vector thứ hai `(bx, by)`. +/// → returns (float, float) +#let vec-add(a, b) = { + let (ax, ay) = a + let (bx, by) = b + (ax + bx, ay + by) +} + +/// Nhân vector 2D với một scalar. +/// +/// - v (array): Vector `(x, y)`. +/// - s (float): Scalar. +/// → returns (float, float) +#let vec-scale(v, s) = { + let (x, y) = v + (x * s, y * s) +} + +/// Tính độ dài (norm) của vector 2D. +/// +/// - v (array): Vector `(x, y)`. +/// → returns float +#let vec-length(v) = { + let (x, y) = v + calc.sqrt(x * x + y * y) +} + +/// Chuẩn hóa vector 2D về độ dài bằng 1. +/// Trả về `(0, 0)` nếu vector có độ dài bằng 0. +/// +/// - v (array): Vector `(x, y)`. +/// → returns (float, float) +#let vec-norm(v) = { + let len = vec-length(v) + if len < 1e-10 { (0.0, 0.0) } else { vec-scale(v, 1.0 / len) } +} + +/// Nội suy tuyến tính giữa hai điểm `A` và `B` theo tham số `t ∈ [0, 1]`. +/// Trả về điểm nằm trên đoạn AB: pt-lerp(A, B, 0) = A, pt-lerp(A, B, 1) = B. +/// +/// - A (array): Điểm đầu `(ax, ay)`. +/// - B (array): Điểm cuối `(bx, by)`. +/// - t (float): Tham số nội suy [0, 1]. +/// → returns (float, float) +#let pt-lerp(A, B, t) = { + let (ax, ay) = A + let (bx, by) = B + (lerp(ax, bx, t), lerp(ay, by, t)) +} diff --git a/packages/preview/sang-math/1.0.0/exam-templates.typ b/packages/preview/sang-math/1.0.0/exam-templates.typ new file mode 100644 index 0000000000..37295de093 --- /dev/null +++ b/packages/preview/sang-math/1.0.0/exam-templates.typ @@ -0,0 +1,387 @@ +// ================================================================ +// SANG-MATH EXAM TEMPLATES v1.0.0 +// Bộ preset giao diện đề thi dùng chung với thpt-school-exam. +// Cách dùng: +// #import "@preview/sang-math:1.0.0": * +// #show: exam-ocean.with(school: "THPT ...", code: "101") +// ================================================================ + +#import "sang-exam.typ": thpt-school-exam, print-answer-key + +#let _template( + body, + accent: rgb(0, 87, 184), + q-style: "pill", + show-topbar: true, + header-border: true, + watermark: none, + watermark-opacity: 0.06, + footer-left: none, + body-font: "Times New Roman", + header-font: "Times New Roman", + two-columns: false, + column-gutter: 14pt, + answer-key: false, + ..args, +) = { + let content = if two-columns { + columns(2, gutter: column-gutter)[#body] + } else { + body + } + + let final-body = if answer-key { + [ + #content + #pagebreak(weak: true) + #print-answer-key() + ] + } else { + content + } + + thpt-school-exam( + final-body, + accent: accent, + q-color: accent, + q-style: q-style, + show-topbar: show-topbar, + header-border: header-border, + watermark: watermark, + watermark-opacity: watermark-opacity, + footer-left: footer-left, + body-font: body-font, + header-font: header-font, + ..args, + ) +} + +// 01. Cổ điển xanh: giống phong cách đề chính thức, gọn và nghiêm túc. +#let exam-classic(body, ..args) = _template( + body, + accent: rgb(0, 87, 184), + q-style: "bold", + show-topbar: false, + header-border: true, + ..args, +) + +// 02. Ocean: xanh ngọc hiện đại, hợp đề luyện tập và đề online. +#let exam-ocean(body, ..args) = _template( + body, + accent: rgb(14, 116, 144), + q-style: "pill", + watermark: [SANG MATH], + ..args, +) + +// 03. Emerald: xanh lá nhẹ, dễ nhìn khi in lời giải. +#let exam-emerald(body, ..args) = _template( + body, + accent: rgb(4, 120, 87), + q-style: "pill", + watermark: [THPT], + ..args, +) + +// 04. Royal: xanh tím sang, hợp đề chọn lọc/chuyên đề. +#let exam-royal(body, ..args) = _template( + body, + accent: rgb(67, 56, 202), + q-style: "boxed", + watermark: [ROYAL], + ..args, +) + +// 05. Violet: nổi bật, hợp đề ôn tốc độ hoặc đề phân hóa. +#let exam-violet(body, ..args) = _template( + body, + accent: rgb(124, 58, 237), + q-style: "pill", + watermark: [VDC], + ..args, +) + +// 06. Crimson: đỏ học thuật, hợp đề thi thử hoặc đề kiểm tra. +#let exam-crimson(body, ..args) = _template( + body, + accent: rgb(190, 18, 60), + q-style: "boxed", + watermark: [EXAM], + ..args, +) + +// 07. Graphite: đen xám tối giản, in laser rất sạch. +#let exam-graphite(body, ..args) = _template( + body, + accent: rgb(51, 65, 85), + q-style: "bold", + show-topbar: false, + watermark: none, + ..args, +) + +// 08. Amber: ấm, hợp phiếu luyện tập hoặc đề cuối tuần. +#let exam-amber(body, ..args) = _template( + body, + accent: rgb(180, 83, 9), + q-style: "pill", + watermark: [PRACTICE], + ..args, +) + +// 09. Teal Pro: cân bằng giữa trang trọng và hiện đại. +#let exam-teal-pro(body, ..args) = _template( + body, + accent: rgb(15, 118, 110), + q-style: "boxed", + watermark: [SANG], + ..args, +) + +// 10. Sky: nhẹ, thoáng, hợp tài liệu phát cho học sinh. +#let exam-sky(body, ..args) = _template( + body, + accent: rgb(2, 132, 199), + q-style: "pill", + watermark: [LEARN], + watermark-opacity: 0.045, + ..args, +) + +// 11. Indigo Minimal: tiết chế màu, nhiều khoảng thở. +#let exam-indigo-minimal(body, ..args) = _template( + body, + accent: rgb(79, 70, 229), + q-style: "bold", + show-topbar: true, + header-border: false, + watermark: none, + ..args, +) + +// 12. Print Economy: tối ưu in đen trắng, vẫn có cấu trúc rõ. +#let exam-print-economy(body, ..args) = _template( + body, + accent: black, + q-style: "bold", + show-topbar: false, + header-border: true, + watermark: none, + ..args, +) + +// 13. Aurora: xanh sapphire + hơi ngọc, nổi bật nhưng vẫn sạch khi in. +#let exam-aurora(body, ..args) = _template( + body, + accent: rgb("#2563eb"), + q-style: "pill", + watermark: [AURORA], + watermark-opacity: 0.04, + ..args, +) + +// 14. Lotus: hồng sen trang nhã, hợp phiếu chuyên đề và tài liệu luyện tập. +#let exam-lotus(body, ..args) = _template( + body, + accent: rgb("#be185d"), + q-style: "pill", + watermark: [LOTUS], + watermark-opacity: 0.04, + ..args, +) + +// 15. Navy Gold: xanh than + vàng, phong cách đề chọn lọc cao cấp. +#let exam-navy-gold(body, ..args) = _template( + body, + accent: rgb("#1d4ed8"), + q-style: "boxed", + watermark: [ELITE], + watermark-opacity: 0.035, + ..args, +) + +// 16. Jade: xanh ngọc sâu, dịu mắt cho đề có nhiều lời giải. +#let exam-jade(body, ..args) = _template( + body, + accent: rgb("#059669"), + q-style: "pill", + watermark: [JADE], + watermark-opacity: 0.045, + ..args, +) + +// 17. Coral: đỏ cam hiện đại, hợp đề kiểm tra ngắn hoặc đề luyện tốc độ. +#let exam-coral(body, ..args) = _template( + body, + accent: rgb("#ea580c"), + q-style: "pill", + watermark: [FAST], + watermark-opacity: 0.04, + ..args, +) + +// 18. Plum: tím mận học thuật, hợp chuyên đề nâng cao. +#let exam-plum(body, ..args) = _template( + body, + accent: rgb("#7e22ce"), + q-style: "boxed", + watermark: [VDC], + watermark-opacity: 0.04, + ..args, +) + +// Dispatch theme theo chuỗi, giúp demo/HDSD không cần if-else dài. +#let exam-theme(body, theme: "royal", ..args) = { + if theme == "classic" { exam-classic(body, ..args) } + else if theme == "ocean" { exam-ocean(body, ..args) } + else if theme == "emerald" { exam-emerald(body, ..args) } + else if theme == "royal" { exam-royal(body, ..args) } + else if theme == "violet" { exam-violet(body, ..args) } + else if theme == "crimson" { exam-crimson(body, ..args) } + else if theme == "graphite" { exam-graphite(body, ..args) } + else if theme == "amber" { exam-amber(body, ..args) } + else if theme == "teal-pro" { exam-teal-pro(body, ..args) } + else if theme == "sky" { exam-sky(body, ..args) } + else if theme == "indigo-minimal" { exam-indigo-minimal(body, ..args) } + else if theme == "print-economy" { exam-print-economy(body, ..args) } + else if theme == "aurora" { exam-aurora(body, ..args) } + else if theme == "lotus" { exam-lotus(body, ..args) } + else if theme == "navy-gold" { exam-navy-gold(body, ..args) } + else if theme == "jade" { exam-jade(body, ..args) } + else if theme == "coral" { exam-coral(body, ..args) } + else if theme == "plum" { exam-plum(body, ..args) } + else { exam-royal(body, ..args) } +} + +#let _theme-style(theme) = { + if theme == "classic" { (accent: rgb(0, 87, 184), opt-style: "circle", q-label-style: "underline", boxed: false) } + else if theme == "ocean" { (accent: rgb(14, 116, 144), opt-style: "hexagon", q-label-style: "pill", boxed: false) } + else if theme == "emerald" { (accent: rgb(4, 120, 87), opt-style: "solid-circle", q-label-style: "badge", boxed: false) } + else if theme == "royal" { (accent: rgb(67, 56, 202), opt-style: "pentagon", q-label-style: "spark", boxed: true) } + else if theme == "violet" { (accent: rgb(124, 58, 237), opt-style: "diamond", q-label-style: "flag", boxed: false) } + else if theme == "crimson" { (accent: rgb(190, 18, 60), opt-style: "solid-hexagon", q-label-style: "solid-pill", boxed: true) } + else if theme == "graphite" { (accent: rgb(51, 65, 85), opt-style: "double-circle", q-label-style: "underline", boxed: false) } + else if theme == "amber" { (accent: rgb(180, 83, 9), opt-style: "rounded-square", q-label-style: "ribbon", boxed: false) } + else if theme == "teal-pro" { (accent: rgb(15, 118, 110), opt-style: "solid-pentagon", q-label-style: "badge", boxed: true) } + else if theme == "sky" { (accent: rgb(2, 132, 199), opt-style: "circle", q-label-style: "pill", boxed: false) } + else if theme == "indigo-minimal" { (accent: rgb(79, 70, 229), opt-style: "plain", q-label-style: "underline", boxed: false) } + else if theme == "print-economy" { (accent: black, opt-style: "plain", q-label-style: "plain", boxed: false) } + else if theme == "aurora" { (accent: rgb("#2563eb"), opt-style: "solid-diamond", q-label-style: "spark", boxed: true) } + else if theme == "lotus" { (accent: rgb("#be185d"), opt-style: "solid-triangle", q-label-style: "flag", boxed: false) } + else if theme == "navy-gold" { (accent: rgb("#1d4ed8"), opt-style: "solid-hexagon", q-label-style: "solid-pill", boxed: true) } + else if theme == "jade" { (accent: rgb("#059669"), opt-style: "hexagon", q-label-style: "badge", boxed: true) } + else if theme == "coral" { (accent: rgb("#ea580c"), opt-style: "solid-square", q-label-style: "ribbon", boxed: false) } + else if theme == "plum" { (accent: rgb("#7e22ce"), opt-style: "pentagon", q-label-style: "spark", boxed: true) } + else { (accent: rgb(67, 56, 202), opt-style: "pentagon", q-label-style: "spark", boxed: true) } +} + +#let _profile-mode(profile) = if profile == "loigiai" or profile == "solution" or profile == "beamer" or profile == "slide" or profile == "slides" { + "loigiai" +} else { + "dethi" +} + +#let _bool-input(name, default: false) = { + let v = sys.inputs.at(name, default: if default { "1" } else { "0" }) + v == "1" or v == "true" or v == "yes" or v == "on" +} + +// Preset gọn cho đề PDF và beamer. +// Dùng: #let preset = exam-preset(theme: "royal", profile: "dethi") +// Sau đó: #let (tn, ds, tln, tl) = exam-mode(..preset.question) +#let exam-preset( + theme: "royal", + profile: "dethi", + mode: auto, + opt-style: auto, + q-label-style: auto, + show-tags: false, + draft: auto, + two-columns: auto, + answer-key: auto, + boxed: auto, +) = { + let s = _theme-style(theme) + let real-mode = if mode == auto { _profile-mode(profile) } else { mode } + let is-beamer = profile == "beamer" or profile == "slide" or profile == "slides" + let real-draft = if draft == auto { profile == "draft" } else { draft } + let real-two-columns = if two-columns == auto { profile == "compact" or profile == "twocol" } else { two-columns } + let real-answer-key = if answer-key == auto { profile == "answer" or profile == "loigiai" } else { answer-key } + let real-boxed = if boxed == auto { s.boxed } else { boxed } + let accent = s.accent + ( + theme: theme, + profile: profile, + mode: real-mode, + accent: accent, + beamer: is-beamer, + question: ( + mode: real-mode, + accent: accent, + opt-style: if opt-style == auto { "plain" } else if opt-style == "theme" { s.opt-style } else { opt-style }, + opt-label-color: if theme == "print-economy" { auto } else { accent }, + q-label-style: if q-label-style == auto { s.q-label-style } else { q-label-style }, + show-tags: show-tags, + draft: real-draft, + draft-width: 28%, + draft-lines: 6, + boxed: real-boxed, + box-fill: accent.lighten(94%), + box-stroke: 0.55pt + accent.lighten(55%), + ), + template: ( + two-columns: real-two-columns, + answer-key: real-answer-key, + beamer: is-beamer, + ), + beamer-theme: ( + accent: accent, + bg_color: if theme == "print-economy" { white } else { rgb("#0f172a") }, + math_color: if theme == "amber" or theme == "coral" { rgb("#fbbf24") } else { rgb("#f59e0b") }, + ), + ) +} + +// Preset đọc từ dòng lệnh compile: +// --input theme=royal --input profile=loigiai --input answer-key=1 +#let exam-input-preset(default-theme: "royal", default-profile: "dethi") = { + let theme = sys.inputs.at("theme", default: default-theme) + let profile = if _bool-input("beamer", default: false) { + "beamer" + } else { + sys.inputs.at("profile", default: sys.inputs.at("mode", default: default-profile)) + } + exam-preset( + theme: theme, + profile: profile, + mode: sys.inputs.at("mode", default: auto), + opt-style: sys.inputs.at("opt-style", default: auto), + q-label-style: sys.inputs.at("q-label-style", default: auto), + draft: _bool-input("draft", default: profile == "draft"), + two-columns: _bool-input("two-columns", default: profile == "compact" or profile == "twocol"), + answer-key: _bool-input("answer-key", default: profile == "answer" or profile == "loigiai"), + ) +} + +// Danh sách tên theme để dùng trong tài liệu/ứng dụng ngoài. +#let exam-template-names = ( + "classic", + "ocean", + "emerald", + "royal", + "violet", + "crimson", + "graphite", + "amber", + "teal-pro", + "sky", + "indigo-minimal", + "print-economy", + "aurora", + "lotus", + "navy-gold", + "jade", + "coral", + "plum", +) diff --git a/packages/preview/sang-math/1.0.0/geometry-2d/conics.typ b/packages/preview/sang-math/1.0.0/geometry-2d/conics.typ new file mode 100644 index 0000000000..f61bc6fba2 --- /dev/null +++ b/packages/preview/sang-math/1.0.0/geometry-2d/conics.typ @@ -0,0 +1,255 @@ +// ═══════════════════════════════════════════════════════════════ +// GEOMETRY-2D / CONICS.TYP — Đường conic (Parabol, Elip, Hyperbol) +// ═══════════════════════════════════════════════════════════════ +// Dùng: #import "geometry-2d/conics.typ": * +// +// DANH SÁCH HÀM: +// parabola-points(...) Mảng tọa độ parabol (data) +// draw-parabola(...) Vẽ parabol với nhãn và anchor +// ellipse-points(...) Mảng tọa độ elip (data) +// draw-ellipse(...) Vẽ elip với nhãn và anchor +// hyperbola-points(...) Mảng tọa độ hyperbol (data) +// draw-hyperbola(...) Vẽ hyperbol với nhãn và anchor +// ═══════════════════════════════════════════════════════════════ + +#import "@preview/cetz:0.5.2" +#import "../core/math-utils.typ": linspace +#import "../core/colors.typ": sm-blue, sm-red, sm-purple + + +// ──────────────────────────────────────────────────────────────── +// PARABOL y = a(x - h)² + k +// ──────────────────────────────────────────────────────────────── + +/// Tính mảng tọa độ của parabol dạng y = a(x-h)² + k. +/// +/// - a (float): Hệ số bậc 2 (a > 0: mở lên, a < 0: mở xuống). +/// - h (float): Hoành độ đỉnh. +/// - k (float): Tung độ đỉnh. +/// - x-range (array): Khoảng x `(xmin, xmax)`. Mặc định: `(-3, 3)`. +/// - steps (int): Số điểm lấy mẫu. Mặc định: `60`. +/// → returns array of (float, float) +#let parabola-points( + a: 1, + h: 0, + k: 0, + x-range: (-3.0, 3.0), + steps: 60, +) = { + let xs = linspace(x-range.at(0), x-range.at(1), steps + 1) + xs.map(x => (x, a * (x - h) * (x - h) + k)) +} + +/// Vẽ parabol y = a(x-h)² + k với đầy đủ nhãn và anchor. +/// +/// - name (str): Tên định danh anchor. Mặc định: `"par"`. +/// - a (float): Hệ số bậc 2. +/// - h (float): Hoành độ đỉnh. +/// - k (float): Tung độ đỉnh. +/// - x-range (array): Khoảng x `(xmin, xmax)`. Mặc định: `(-3, 3)`. +/// - color (color): Màu nét. Mặc định: sm-blue. +/// - stroke-width (length): Độ dày nét. Mặc định: `1.5pt`. +/// - label-vertex (content / none): Nhãn tại đỉnh. Ví dụ: `[$P$]`. +/// - fill (color / none): Tô màu bên trong nếu cắt trục x. +#let draw-parabola( + name: "par", + a: 1, + h: 0, + k: 0, + x-range: (-3.0, 3.0), + color: rgb("1565C0"), // sm-func-1 / sm-blue + stroke-width: 1.5pt, + label-vertex: none, + steps: 60, +) = { + let d = cetz.draw + let pts = parabola-points(a: a, h: h, k: k, x-range: x-range, steps: steps) + + d.group(name: name, { + d.line(..pts, stroke: stroke-width + color) + + if label-vertex != none { + let offset = if a > 0 { -0.4 } else { 0.4 } + d.content((h, k + offset), label-vertex) + } + + // ── Anchors ──────────────────────────────────────────── + d.anchor("vertex", (h, k)) + d.anchor("left", (x-range.at(0), a * (x-range.at(0) - h) * (x-range.at(0) - h) + k)) + d.anchor("right", (x-range.at(1), a * (x-range.at(1) - h) * (x-range.at(1) - h) + k)) + }) +} + + +// ──────────────────────────────────────────────────────────────── +// ELIP x²/a² + y²/b² = 1 +// ──────────────────────────────────────────────────────────────── + +/// Tính mảng tọa độ của elip x²/a² + y²/b² = 1. +/// +/// - a (float): Bán trục dài (theo x). +/// - b (float): Bán trục ngắn (theo y). +/// - center (array): Tâm elip `(cx, cy)`. Mặc định: `(0, 0)`. +/// - steps (int): Số điểm lấy mẫu. Mặc định: `120`. +/// → returns array of (float, float) +#let ellipse-points( + a: 2, + b: 1, + center: (0.0, 0.0), + steps: 120, +) = { + let (cx, cy) = center + let angles = linspace(0, 2 * calc.pi, steps + 1) + angles.map(t => (cx + a * calc.cos(t), cy + b * calc.sin(t))) +} + +/// Vẽ elip x²/a² + y²/b² = 1 với đầy đủ nhãn và anchor. +/// +/// - name (str): Tên định danh anchor. Mặc định: `"ell"`. +/// - a (float): Bán trục dài (theo x). +/// - b (float): Bán trục ngắn (theo y). +/// - center (array): Tâm elip `(cx, cy)`. Mặc định: `(0, 0)`. +/// - color (color): Màu nét. Mặc định: sm-purple. +/// - stroke-width (length): Độ dày nét. Mặc định: `1.5pt`. +/// - fill (color / none): Tô màu bên trong. Mặc định: `none`. +/// - show-axes (bool): Vẽ nửa trục (nét đứt). Mặc định: `false`. +/// - show-foci (bool): Đánh dấu 2 tiêu điểm F1, F2. Mặc định: `false`. +#let draw-ellipse( + name: "ell", + a: 2, + b: 1, + center: (0.0, 0.0), + color: rgb("7B1FA2"), // sm-purple + stroke-width: 1.5pt, + fill: none, + show-axes: false, + show-foci: false, + steps: 120, +) = { + let d = cetz.draw + let (cx, cy) = center + let pts = ellipse-points(a: a, b: b, center: center, steps: steps) + let c-focal = calc.sqrt(calc.abs(a * a - b * b)) + + d.group(name: name, { + if fill != none { + d.line(..pts, close: true, fill: fill, stroke: stroke-width + color) + } else { + d.line(..pts, close: true, stroke: stroke-width + color) + } + + if show-axes { + d.line((cx - a, cy), (cx + a, cy), + stroke: (dash: "dashed", paint: rgb("1976D2"), thickness: 0.8pt)) + d.line((cx, cy - b), (cx, cy + b), + stroke: (dash: "dashed", paint: rgb("1976D2"), thickness: 0.8pt)) + } + + if show-foci and a > b { + d.circle((cx + c-focal, cy), radius: 0.08, fill: rgb("E91E63")) + d.circle((cx - c-focal, cy), radius: 0.08, fill: rgb("E91E63")) + d.content((cx + c-focal, cy - 0.3), [$F_2$]) + d.content((cx - c-focal, cy - 0.3), [$F_1$]) + } + + // ── Anchors ──────────────────────────────────────────── + d.anchor("center", (cx, cy)) + d.anchor("right", (cx + a, cy)) + d.anchor("left", (cx - a, cy)) + d.anchor("top", (cx, cy + b)) + d.anchor("bottom", (cx, cy - b)) + if a > b { + d.anchor("focus-1", (cx - c-focal, cy)) + d.anchor("focus-2", (cx + c-focal, cy)) + } + }) +} + + +// ──────────────────────────────────────────────────────────────── +// HYPERBOL x²/a² - y²/b² = 1 +// ──────────────────────────────────────────────────────────────── + +/// Tính mảng tọa độ của một nhánh hyperbol (nhánh phải: x > 0). +/// +/// - a (float): Hệ số a của hyperbol. +/// - b (float): Hệ số b của hyperbol. +/// - branch (str): `"right"` hoặc `"left"`. Mặc định: `"right"`. +/// - y-range (array): Khoảng y `(ymin, ymax)`. Mặc định: `(-3, 3)`. +/// - center (array): Tâm hyperbol. Mặc định: `(0, 0)`. +/// - steps (int): Số điểm lấy mẫu. Mặc định: `60`. +/// → returns array of (float, float) +#let hyperbola-branch-points( + a: 1, + b: 1, + branch: "right", + y-range: (-3.0, 3.0), + center: (0.0, 0.0), + steps: 60, +) = { + let (cx, cy) = center + let ys = linspace(y-range.at(0), y-range.at(1), steps + 1) + let sign = if branch == "right" { 1 } else { -1 } + ys.map(y => (cx + sign * a * calc.sqrt(1 + (y - cy) * (y - cy) / (b * b)), y)) +} + +/// Vẽ cả hai nhánh của hyperbol x²/a² - y²/b² = 1. +/// +/// - name (str): Tên định danh anchor. Mặc định: `"hyp"`. +/// - a (float): Hệ số a. +/// - b (float): Hệ số b. +/// - center (array): Tâm `(cx, cy)`. Mặc định: `(0, 0)`. +/// - y-range (array): Khoảng y `(ymin, ymax)`. Mặc định: `(-3, 3)`. +/// - color (color): Màu nét. Mặc định: sm-red. +/// - stroke-width (length): Độ dày nét. Mặc định: `1.5pt`. +/// - show-asymptotes (bool): Vẽ đường tiệm cận. Mặc định: `false`. +/// - show-foci (bool): Đánh dấu tiêu điểm. Mặc định: `false`. +#let draw-hyperbola( + name: "hyp", + a: 1, + b: 1, + center: (0.0, 0.0), + y-range: (-3.0, 3.0), + color: rgb("E91E63"), // sm-red + stroke-width: 1.5pt, + show-asymptotes: false, + show-foci: false, + steps: 60, +) = { + import cetz.draw: * + let (cx, cy) = center + let c-focal = calc.sqrt(a * a + b * b) + let pts-r = hyperbola-branch-points(a: a, b: b, branch: "right", + y-range: y-range, center: center, steps: steps) + let pts-l = hyperbola-branch-points(a: a, b: b, branch: "left", + y-range: y-range, center: center, steps: steps) + + group(name: name, { + line(..pts-r, stroke: stroke-width + color) + line(..pts-l, stroke: stroke-width + color) + + if show-asymptotes { + let x-range-asm = (y-range.at(0) * a / b, y-range.at(1) * a / b) + line((cx + x-range-asm.at(0), cy + y-range.at(0)), + (cx + x-range-asm.at(1), cy + y-range.at(1)), + stroke: (dash: "dashed", paint: rgb("1976D2"), thickness: 0.8pt)) + line((cx - x-range-asm.at(0), cy + y-range.at(0)), + (cx - x-range-asm.at(1), cy + y-range.at(1)), + stroke: (dash: "dashed", paint: rgb("1976D2"), thickness: 0.8pt)) + } + + if show-foci { + circle((cx + c-focal, cy), radius: 0.08, fill: rgb("E91E63")) + circle((cx - c-focal, cy), radius: 0.08, fill: rgb("E91E63")) + content((cx + c-focal, cy - 0.3), [$F_2$]) + content((cx - c-focal, cy - 0.3), [$F_1$]) + } + + // ── Anchors ──────────────────────────────────────────── + anchor("center", (cx, cy)) + anchor("right", (cx + a, cy)) + anchor("left", (cx - a, cy)) + anchor("focus-1", (cx - c-focal, cy)) + anchor("focus-2", (cx + c-focal, cy)) + }) +} diff --git a/packages/preview/sang-math/1.0.0/geometry-3d/curves-3d.typ b/packages/preview/sang-math/1.0.0/geometry-3d/curves-3d.typ new file mode 100644 index 0000000000..c69505ce3c --- /dev/null +++ b/packages/preview/sang-math/1.0.0/geometry-3d/curves-3d.typ @@ -0,0 +1,208 @@ +// ═══════════════════════════════════════════════════════════════ +// GEOMETRY-3D / CURVES-3D.TYP — Đường cong trong không gian 3D +// (chiếu phối cảnh 2D để in ra giấy) +// ═══════════════════════════════════════════════════════════════ +// Dùng: #import "geometry-3d/curves-3d.typ": * +// +// DANH SÁCH HÀM: +// helix-points(...) Trả về mảng tọa độ (x,y) của đường xoắn ốc +// draw-helix(...) Vẽ đường xoắn ốc (bọc helix-points) +// draw-spring(...) Vẽ lò xo (helix dày hơn, nhiều vòng hơn) +// ─────────────────────────────────────────────────────────────── +// CÁCH DÙNG MẪU (bài kiến bò trên mặt trụ): +// +// #import "@local/sang-math:2.0.0": * +// #cetz.canvas(length: 0.8cm, { +// import cetz.draw: * +// +// // Vẽ khung trụ trước... +// // Vẽ đường xoắn chỉ cần 1 dòng: +// draw-helix( +// center: (-8, 0), // Tâm đáy dưới +// radius: 2, // Bán kính trụ +// height: 8, // Chiều cao trụ +// loops: 1.5, // 1.5 vòng xoắn +// color: sm-red, +// ) +// }) +// ═══════════════════════════════════════════════════════════════ + +#import "@preview/cetz:0.5.2" +#import "../core/math-utils.typ": linspace, lerp + +// ── Hằng nội bộ ─────────────────────────────────────────────── +// Tỉ lệ co theo trục Y để tạo ảo giác phối cảnh (ellipse đáy) +// 0.3 = đáy trụ trông như ellipse thoải nhẹ — cảm giác 3D tự nhiên +#let _PERSP-Y = 0.3 + +// ── HÀM DATA ────────────────────────────────────────────────── + +/// Tính mảng tọa độ (x, y) chiếu phối cảnh của đường xoắn ốc. +/// Đây là hàm "data thuần" — không vẽ, chỉ trả về mảng điểm. +/// Người dùng có thể lấy ra để chỉnh sửa trước khi vẽ. +/// +/// - center (array): Tọa độ tâm đáy dưới `(cx, cy)`. Mặc định: `(0, 0)`. +/// - radius (float): Bán kính của đường tròn đáy (trục X). +/// - height (float): Chiều cao tổng của đường xoắn. +/// - loops (float): Số vòng xoắn. Mặc định: `1`. +/// - steps (int): Số đoạn xấp xỉ. Mặc định: `60` (mỗi vòng = 60/loops điểm). +/// - persp-y (float): Tỉ lệ phối cảnh theo trục Y dọc (0.1–0.5). Mặc định: `0.3`. +/// - offset-angle (float): Góc bắt đầu tính bằng radian. Mặc định: `calc.pi` (bắt đầu từ điểm xa nhất). +/// → returns array of (float, float) +#let helix-points( + center: (0, 0), + radius: 1, + height: 4, + loops: 1, + steps: 60, + persp-y: 0.3, + offset-angle: calc.pi, // π = bắt đầu từ điểm trái nhất (xa người xem) +) = { + let (cx, cy) = center + let total-angle = loops * 2 * calc.pi + let ts = linspace(0, 1, steps + 1) + ts.map(t => { + let angle = offset-angle + t * total-angle + let x = cx + radius * calc.cos(angle) + let y = cy + t * height + radius * persp-y * calc.sin(angle) + (x, y) + }) +} + +/// Chia mảng điểm helix thành các segment visible/hidden theo sin(angle). +/// Trả về tuple (vis-segs, hid-segs): mỗi seg là mảng điểm. +/// Lưu ý: Typst array là immutable, dùng += (item,) để thêm phần tử. +/// +/// - pts (array): Mảng tọa độ từ helix-points. +/// - loops (float): Số vòng xoắn. +/// - offset-angle (float): Góc bắt đầu khớp với helix-points. +#let helix-split-visible(pts, loops: 1, offset-angle: calc.pi) = { + let n = pts.len() + let total-angle = loops * 2 * calc.pi + let vis-segs = () + let hid-segs = () + let cur = () + let cur-front = none + + for i in range(0, n) { + let t = if n > 1 { i / (n - 1) } else { 0.0 } + let angle = offset-angle + t * total-angle + let is-front = calc.sin(angle) >= 0 + let pt = pts.at(i) + + if cur-front == none { + cur = (pt,) + cur-front = is-front + } else if is-front == cur-front { + cur += (pt,) + } else { + // Thêm điểm giao để các segment nối liền nhau + cur += (pt,) + if cur.len() >= 2 { + if cur-front { vis-segs += (cur,) } else { hid-segs += (cur,) } + } + cur = (pt,) + cur-front = is-front + } + } + + // Flush segment cuối + if cur.len() >= 2 { + if cur-front { vis-segs += (cur,) } else { hid-segs += (cur,) } + } + (vis-segs, hid-segs) +} + + +// ── HÀM VẼ ──────────────────────────────────────────────────── + +/// Vẽ đường xoắn ốc (helix) quanh trục thẳng đứng. +/// Tự động xử lý phần khuất (nét đứt) và phần nhìn thấy (nét liền). +/// +/// Hữu dụng cho bài toán: kiến bò trên mặt trụ, +/// dây quấn quanh ống, đường ren vít. +/// +/// - center (array): Tọa độ tâm đáy dưới `(cx, cy)`. Mặc định: `(0, 0)`. +/// - radius (float): Bán kính của đường tròn đáy. +/// - height (float): Chiều cao tổng của đường xoắn. +/// - loops (float): Số vòng xoắn. Mặc định: `1`. +/// - steps (int): Số đoạn xấp xỉ. Mặc định: `60`. +/// - color (color): Màu nét vẽ. Mặc định: `rgb("E91E63")`. +/// - stroke-width (length): Độ dày nét. Mặc định: `1.5pt`. +/// - auto-dashed (bool): Tự động vẽ phần khuất bằng nét đứt. Mặc định: `true`. +/// - persp-y (float): Tỉ lệ phối cảnh ellipse. Mặc định: `0.3`. +/// - offset-angle (float): Góc bắt đầu (radian). Mặc định: `calc.pi`. +#let draw-helix( + center: (0, 0), + radius: 1, + height: 4, + loops: 1, + steps: 60, + color: rgb("E91E63"), + stroke-width: 1.5pt, + auto-dashed: true, + persp-y: 0.3, + offset-angle: calc.pi, +) = { + let d = cetz.draw + let pts = helix-points( + center: center, + radius: radius, + height: height, + loops: loops, + steps: steps, + persp-y: persp-y, + offset-angle: offset-angle, + ) + + if auto-dashed { + let (vis-segs, hid-segs) = helix-split-visible( + pts, + loops: loops, + offset-angle: offset-angle, + ) + for seg in vis-segs { + if seg.len() >= 2 { + d.line(..seg, stroke: stroke-width + color) + } + } + for seg in hid-segs { + if seg.len() >= 2 { + d.line(..seg, stroke: (dash: "dashed", paint: color, thickness: stroke-width)) + } + } + } else { + // Vẽ đơn giản: toàn bộ 1 màu, 1 nét + d.line(..pts, stroke: stroke-width + color) + } +} + +/// Vẽ lò xo (nhiều vòng hơn helix, bán kính nhỏ, thường dùng cho bài vật lý-toán). +/// Là wrapper của draw-helix với preset phù hợp. +/// +/// - center (array): Tọa độ tâm đáy dưới. +/// - radius (float): Bán kính lò xo. Mặc định: `0.4`. +/// - height (float): Chiều dài lò xo. +/// - coils (float): Số vòng cuộn. Mặc định: `6`. +/// - color (color): Màu nét. Mặc định: `rgb("1976D2")`. +/// - stroke-width (length): Độ dày nét. Mặc định: `1.2pt`. +#let draw-spring( + center: (0, 0), + radius: 0.4, + height: 4, + coils: 6, + color: rgb("1976D2"), + stroke-width: 1.2pt, +) = { + draw-helix( + center: center, + radius: radius, + height: height, + loops: coils, + steps: coils * 30, + color: color, + stroke-width: stroke-width, + auto-dashed: false, // Lò xo không cần nét đứt + persp-y: 0.5, + ) +} diff --git a/packages/preview/sang-math/1.0.0/geometry-3d/revolution.typ b/packages/preview/sang-math/1.0.0/geometry-3d/revolution.typ new file mode 100644 index 0000000000..ccfd2277e2 --- /dev/null +++ b/packages/preview/sang-math/1.0.0/geometry-3d/revolution.typ @@ -0,0 +1,253 @@ +// ═══════════════════════════════════════════════════════════════ +// GEOMETRY-3D / REVOLUTION.TYP — Hình khối tròn xoay +// (Nón, Trụ, Cầu — chiếu phối cảnh 2D) +// ═══════════════════════════════════════════════════════════════ +// Dùng: #import "geometry-3d/revolution.typ": * +// +// DANH SÁCH HÀM: +// draw-cylinder(...) Hình trụ với đầy đủ anchor +// draw-cone(...) Hình nón với đầy đủ anchor +// draw-sphere(...) Hình cầu với đầy đủ anchor +// ─────────────────────────────────────────────────────────────── +// CÁC ANCHOR CÓ SẴN (truyền vào draw.content, draw.line, v.v.): +// +// Trụ (draw-cylinder name: "C"): +// C.top Tâm mặt trên +// C.bottom Tâm mặt dưới +// C.top-left Điểm trái mặt trên (điểm tiếp xúc đường sinh) +// C.top-right Điểm phải mặt trên +// C.bot-left Điểm trái mặt dưới +// C.bot-right Điểm phải mặt dưới +// C.center Trung điểm trục +// +// Nón (draw-cone name: "N"): +// N.apex Đỉnh S +// N.center Tâm đáy O +// N.left Điểm trái đáy +// N.right Điểm phải đáy +// +// Cầu (draw-sphere name: "S"): +// S.center Tâm O +// S.top Điểm cực Bắc +// S.bottom Điểm cực Nam +// S.left Điểm cực Tây +// S.right Điểm cực Đông +// ═══════════════════════════════════════════════════════════════ + +#import "@preview/cetz:0.5.2" +#import "../core/colors.typ": sm-blue, sm-gray, sm-face-top, sm-face-front + +// ── Tỉ lệ phối cảnh mặc định (bán trục ngắn ellipse / bán trục dài) ── +#let _PERSP = 0.35 + + +// ──────────────────────────────────────────────────────────────── +// HÌNH TRỤ +// ──────────────────────────────────────────────────────────────── + +/// Vẽ hình trụ đứng chiếu phối cảnh, kèm theo các anchor. +/// +/// - name (str): Tên định danh để tham chiếu anchor. Mặc định: `"cyl"`. +/// - center (array): Tọa độ tâm đáy dưới `(cx, cy)`. Mặc định: `(0, 0)`. +/// - radius (float): Bán kính đáy. +/// - height (float): Chiều cao hình trụ. +/// - color (color): Màu nét viền. Mặc định: sm-blue. +/// - stroke-width (length): Độ dày nét. Mặc định: `1.5pt`. +/// - fill-side (color / none): Tô màu thân trụ. Mặc định: `none`. +/// - fill-top (color / none): Tô màu mặt trên. Mặc định: `none`. +/// - persp (float): Tỉ lệ phối cảnh ellipse. Mặc định: `0.35`. +/// - show-hidden (bool): Vẽ nét đứt cho phần khuất của đáy dưới. Mặc định: `true`. +#let draw-cylinder( + name: "cyl", + center: (0, 0), + radius: 1, + height: 4, + color: rgb("1976D2"), // sm-blue + stroke-width: 1.5pt, + fill-side: none, + fill-top: none, + persp: 0.35, + show-hidden: true, +) = { + let d = cetz.draw + let (cx, cy) = center + let ry = radius * persp + + d.group(name: name, { + if fill-side != none { + let fill-pts = ( + (cx - radius, cy), + (cx - radius, cy + height), + (cx + radius, cy + height), + (cx + radius, cy), + ) + d.line(..fill-pts, close: true, fill: fill-side) + } + + if show-hidden { + d.arc((cx, cy), start: 0deg, stop: 180deg, + radius: (radius, ry), + stroke: (dash: "dashed", paint: rgb("888888"), thickness: 0.8pt)) + } + d.arc((cx, cy), start: 0deg, stop: -180deg, + radius: (radius, ry), + stroke: stroke-width + color) + + if fill-top != none { + d.arc((cx, cy + height), start: 0deg, stop: 360deg, + radius: (radius, ry), + fill: fill-top, + stroke: stroke-width + color) + } else { + d.arc((cx, cy + height), start: 0deg, stop: 360deg, + radius: (radius, ry), + stroke: stroke-width + color) + } + + d.line((cx - radius, cy), (cx - radius, cy + height), stroke: stroke-width + color) + d.line((cx + radius, cy), (cx + radius, cy + height), stroke: stroke-width + color) + + d.anchor("bottom", (cx, cy)) + d.anchor("top", (cx, cy + height)) + d.anchor("center", (cx, cy + height / 2)) + d.anchor("bot-left", (cx - radius, cy)) + d.anchor("bot-right", (cx + radius, cy)) + d.anchor("top-left", (cx - radius, cy + height)) + d.anchor("top-right", (cx + radius, cy + height)) + }) +} + + +// ──────────────────────────────────────────────────────────────── +// HÌNH NÓN +// ──────────────────────────────────────────────────────────────── + +/// Vẽ hình nón đứng chiếu phối cảnh, kèm theo các anchor. +/// +/// - name (str): Tên định danh để tham chiếu anchor. Mặc định: `"cone"`. +/// - center (array): Tọa độ tâm đáy `(cx, cy)`. Mặc định: `(0, 0)`. +/// - radius (float): Bán kính đáy. +/// - height (float): Chiều cao hình nón. +/// - color (color): Màu nét viền. Mặc định: sm-blue. +/// - stroke-width (length): Độ dày nét. Mặc định: `1.5pt`. +/// - fill-base (color / none): Tô màu mặt đáy. Mặc định: `none`. +/// - persp (float): Tỉ lệ phối cảnh ellipse. Mặc định: `0.35`. +/// - show-hidden (bool): Vẽ nét đứt phần khuất của đáy. Mặc định: `true`. +/// - label-apex (content / none): Nhãn tại đỉnh. Ví dụ: `[$S$]`. +/// - label-center (content / none): Nhãn tại tâm đáy. Ví dụ: `[$O$]`. +#let draw-cone( + name: "cone", + center: (0, 0), + radius: 1, + height: 4, + color: rgb("1976D2"), // sm-blue + stroke-width: 1.5pt, + fill-base: none, + persp: 0.35, + show-hidden: true, + label-apex: none, + label-center: none, +) = { + let d = cetz.draw + let (cx, cy) = center + let ry = radius * persp + let apex = (cx, cy + height) + + d.group(name: name, { + if show-hidden { + d.arc((cx, cy), start: 0deg, stop: 180deg, + radius: (radius, ry), + stroke: (dash: "dashed", paint: rgb("888888"), thickness: 0.8pt)) + } + if fill-base != none { + d.arc((cx, cy), start: 0deg, stop: -180deg, + radius: (radius, ry), + fill: fill-base, + stroke: stroke-width + color) + } else { + d.arc((cx, cy), start: 0deg, stop: -180deg, + radius: (radius, ry), + stroke: stroke-width + color) + } + + d.line((cx - radius, cy), apex, stroke: stroke-width + color) + d.line((cx + radius, cy), apex, stroke: stroke-width + color) + + if label-apex != none { + d.content((cx, cy + height + 0.3), label-apex, anchor: "south") + } + if label-center != none { + d.content((cx, cy - 0.3), label-center, anchor: "north") + } + + d.anchor("apex", apex) + d.anchor("center", (cx, cy)) + d.anchor("left", (cx - radius, cy)) + d.anchor("right", (cx + radius, cy)) + d.anchor("mid", (cx, cy + height / 2)) + }) +} + + +// ──────────────────────────────────────────────────────────────── +// HÌNH CẦU +// ──────────────────────────────────────────────────────────────── + +/// Vẽ hình cầu chiếu phối cảnh (đường tròn ngoài + đường xích đạo ellipse). +/// +/// - name (str): Tên định danh để tham chiếu anchor. Mặc định: `"sph"`. +/// - center (array): Tọa độ tâm cầu `(cx, cy)`. Mặc định: `(0, 0)`. +/// - radius (float): Bán kính cầu. +/// - color (color): Màu nét viền. Mặc định: sm-blue. +/// - stroke-width (length): Độ dày nét. Mặc định: `1.5pt`. +/// - fill (color / none): Tô màu mặt cầu. Mặc định: `none`. +/// - persp (float): Tỉ lệ phối cảnh ellipse xích đạo. Mặc định: `0.35`. +/// - show-equator (bool): Vẽ đường xích đạo. Mặc định: `true`. +/// - show-meridian (bool): Vẽ kinh tuyến đứng. Mặc định: `false`. +#let draw-sphere( + name: "sph", + center: (0, 0), + radius: 1, + color: rgb("1976D2"), // sm-blue literal + stroke-width: 1.5pt, + fill: none, + persp: 0.35, // _PERSP literal + show-equator: true, + show-meridian: false, +) = { + let d = cetz.draw + let (cx, cy) = center + let ry-eq = radius * persp + + d.group(name: name, { + if fill != none { + d.circle((cx, cy), radius: radius, fill: fill, stroke: stroke-width + color) + } else { + d.circle((cx, cy), radius: radius, stroke: stroke-width + color) + } + + if show-equator { + d.arc((cx, cy), start: 0deg, stop: 180deg, + radius: (radius, ry-eq), + stroke: (dash: "dashed", paint: rgb("888888"), thickness: 0.8pt)) + d.arc((cx, cy), start: 0deg, stop: -180deg, + radius: (radius, ry-eq), + stroke: stroke-width + color) + } + + if show-meridian { + d.arc((cx, cy), start: 90deg, stop: 270deg, + radius: (ry-eq, radius), + stroke: (dash: "dashed", paint: rgb("888888"), thickness: 0.8pt)) + d.arc((cx, cy), start: -90deg, stop: 90deg, + radius: (ry-eq, radius), + stroke: stroke-width + color) + } + + d.anchor("center", (cx, cy)) + d.anchor("top", (cx, cy + radius)) + d.anchor("bottom", (cx, cy - radius)) + d.anchor("left", (cx - radius, cy)) + d.anchor("right", (cx + radius, cy)) + }) +} diff --git a/packages/preview/sang-math/1.0.0/geometry.typ b/packages/preview/sang-math/1.0.0/geometry.typ new file mode 100644 index 0000000000..53b1ee6a51 --- /dev/null +++ b/packages/preview/sang-math/1.0.0/geometry.typ @@ -0,0 +1,488 @@ +// ═══════════════════════════════════════════════════════════ +// GEOMETRY.TYP v1.0 — Vẽ hình học nhanh với Cetz +// ═══════════════════════════════════════════════════════════ +// Import: #import "@preview/cetz:0.5.2" +// #import "geometry.typ": * +// +// Các hàm vẽ một canvas hoàn chỉnh bọc sẵn cetz.canvas(). +// Mỗi hàm trả về 1 canvas — dùng trực tiếp làm fig: trong tn/ds/tln/tl. +// ───────────────────────────────────────────────────────── +// DANH SÁCH HÀM: +// tri-xyz(a,b,c) Tam giác ABC (toạ độ thật) +// tri-angles(a,α,β) Tam giác từ 1 cạnh + 2 góc +// tri-right(...) Tam giác vuông +// rect-abc(a,b) Hình chữ nhật / vuông +// chóp-sabc(h,...) Hình chóp S.ABC +// chóp-sabcd(h,...) Hình chóp S.ABCD +// lang-tru-abc(h,...) Lăng trụ đứng ABC.A'B'C' +// circle-desc(r) Đường tròn tâm O, bán kính r +// angle-mark(A,O,B) Đánh dấu góc ∠AOB +// right-angle(A,O,B) Đánh dấu góc vuông tại O +// seg-label(A,B,text,pos) Gán nhãn đoạn thẳng +// point-label(p,text,pos) Gán nhãn điểm +// axis-xy(xmin,xmax,ymin,ymax) Trục toạ độ Oxy +// parabola(a,b,c,...) Parabol y = ax²+bx+c +// dashed-line(A,B) Đường đứt nét +// ───────────────────────────────────────────────────────── + +#import "@preview/cetz:0.5.2" + +// ── Hằng ───────────────────────────────────────────────── +#let _ge-blue = rgb("#0057b8") +#let _ge-red = rgb("#cc2200") +#let _ge-green = rgb("#1a7a2e") +#let _ge-gray = rgb("#888") +#let _ge-bg = rgb("#fafafa") +#let _ge-scale = 0.7cm +#let _ge-padding = 0.4 + +// ── Tam giác từ 3 điểm cho trước ─────────────────────── +// tri-xyz(A: (x,y), B: (x,y), C: (x,y), labels: (...)) +#let tri-xyz(A, B, C, labels: ("A", "B", "C"), right-angle: none, scale: _ge-scale) = { + cetz.canvas(length: scale, { + import cetz.draw: * + line(A, B, C, A, stroke: 1.2pt + black) + for ((p, lbl)) in ((A, labels.at(0)), (B, labels.at(1)), (C, labels.at(2))) { + circle(p, radius: 2.5pt, fill: black) + let (x, y) = p + let dx = 0.25; let dy = 0.25 + if p == A { (dx, dy) = (-0.3, -0.35) } + if p == B { (dx, dy) = (0.3, -0.35) } + if p == C { (dx, dy) = (0, 0.35) } + content((x + dx, y + dy), text(size: 9pt, weight: "bold")[#lbl]) + } + if right-angle != none { + let pts = (A, B, C) + let i = if right-angle == "A" { 0 } else if right-angle == "B" { 1 } else if right-angle == "C" { 2 } else { -1 } + if i >= 0 { + let p = pts.at(i) + let p1 = pts.at(calc.rem(i + 1, 3)) + let p2 = pts.at(calc.rem(i + 2, 3)) + let s = 0.4 + let v1 = ((p1.at(0) - p.at(0), p1.at(1) - p.at(1))) + let v2 = ((p2.at(0) - p.at(0), p2.at(1) - p.at(1))) + let d1 = calc.sqrt(v1.at(0) * v1.at(0) + v1.at(1) * v1.at(1)) + let d2 = calc.sqrt(v2.at(0) * v2.at(0) + v2.at(1) * v2.at(1)) + let u1 = (v1.at(0) / d1 * s, v1.at(1) / d1 * s) + let u2 = (v2.at(0) / d2 * s, v2.at(1) / d2 * s) + line((p.at(0) + u1.at(0), p.at(1) + u1.at(1)), + (p.at(0) + u1.at(0) + u2.at(0), p.at(1) + u1.at(1) + u2.at(1)), + (p.at(0) + u2.at(0), p.at(1) + u2.at(1)), + stroke: 0.6pt + black) + } + } + }) +} + +// ── Tam giác thường — cạnh đáy ngang, đỉnh trên ──────── +// base = độ dài đáy, height = chiều cao +#let tri-abc(base: 5, height: 3.5, labels: ("A", "B", "C"), scale: _ge-scale) = { + let hw = base / 2 + tri-xyz( + (-hw, 0), (hw, 0), (0, height), + labels: labels, + scale: scale, + ) +} + +// ── Tam giác vuông tại A ──────────────────────────────── +#let tri-right(leg1: 4, leg2: 3, labels: ("A", "B", "C"), right-angle-vertex: "A", scale: _ge-scale) = { + tri-xyz( + (0, 0), (leg1, 0), (0, leg2), + labels: labels, + right-angle: right-angle-vertex, + scale: scale, + ) +} + +// ── Hình chữ nhật / vuông ────────────────────────────── +#let rect-xyz(A: (0, 0), C: (5, 3), labels: ("A", "B", "C", "D"), scale: _ge-scale) = { + cetz.canvas(length: scale, { + import cetz.draw: * + let (ax, ay) = A; let (cx, cy) = C + let B = (cx, ay); let D = (ax, cy) + line(A, B, C, D, A, stroke: 1.2pt + black) + for ((p, lbl)) in ((A, labels.at(0)), (B, labels.at(1)), (C, labels.at(2)), (D, labels.at(3))) { + circle(p, radius: 2.5pt, fill: black) + let (x, y) = p + let (dx, dy) = if p == A { (-0.3, -0.35) } + else if p == B { (0.3, -0.35) } + else if p == C { (0.3, 0.35) } + else { (-0.3, 0.35) } + content((x + dx, y + dy), text(size: 9pt, weight: "bold")[#lbl]) + } + }) +} + +// ── Hình chữ nhật đơn giản ───────────────────────────── +#let rect-abc(width: 5, height: 3, labels: ("A", "B", "C", "D"), scale: _ge-scale) = { + rect-xyz(A: (0, 0), C: (width, height), labels: labels, scale: scale) +} + +// ── Hình vuông ───────────────────────────────────────── +#let square(a: 4, labels: ("A", "B", "C", "D"), scale: _ge-scale) = { + rect-abc(width: a, height: a, labels: labels, scale: scale) +} + +// ═══════════════════════════════════════════════════════════ +// HÌNH HỌC KHÔNG GIAN (Phối cảnh Cavalier) +// ═══════════════════════════════════════════════════════════ + +// ── Hình chóp tam giác S.ABC ──────────────────────────── +// base: 3 điểm đáy, S: đỉnh +#let chop-sabc( + S: (0, 8), + A: (-4, 0), B: (4, 0), C: (0, -2), + labels: ("S", "A", "B", "C"), + hidden: (), // tuple đỉnh bị che: ("AC",) → AC nét đứt + scale: _ge-scale, +) = { + cetz.canvas(length: scale, { + import cetz.draw: * + let pts = (S, A, B, C) + let edges = ((0, 1), (0, 2), (0, 3), (1, 2), (2, 3), (3, 1)) + for e in edges { + let (i, j) = e + let lbl = labels.at(i) + labels.at(j) + let rev = labels.at(j) + labels.at(i) + let st = if hidden.contains(lbl) or hidden.contains(rev) { + (paint: _ge-gray, thickness: 0.7pt, dash: "dashed") + } else { + 1.1pt + black + } + line(pts.at(i), pts.at(j), stroke: st) + } + for (i, lbl) in labels.enumerate() { + let (x, y) = pts.at(i) + circle((x, y), radius: 2.5pt, fill: if i == 0 { _ge-blue } else { black }) + let (dx, dy) = if lbl == "S" { (0, 0.45) } + else if lbl == "A" { (-0.4, -0.3) } + else if lbl == "B" { (0.4, -0.3) } + else { (0.1, -0.4) } + content((x + dx, y + dy), text(size: 10pt, weight: "bold")[#lbl]) + } + }) +} + +// ── Hình chóp tứ giác S.ABCD ──────────────────────────── +#let chop-sabcd( + S: (0, 7), + A: (-3, 0), B: (3, 0), C: (2, -2), D: (-2, -2), + labels: ("S", "A", "B", "C", "D"), + hidden: (), + scale: _ge-scale, +) = { + cetz.canvas(length: scale, { + import cetz.draw: * + let pts = (S, A, B, C, D) + let edges = ((0, 1), (0, 2), (0, 3), (0, 4), (1, 2), (2, 3), (3, 4), (4, 1)) + for e in edges { + let (i, j) = e + let lbl = labels.at(i) + labels.at(j) + let rev = labels.at(j) + labels.at(i) + let st = if hidden.contains(lbl) or hidden.contains(rev) { + (paint: _ge-gray, thickness: 0.7pt, dash: "dashed") + } else { + 1.1pt + black + } + line(pts.at(i), pts.at(j), stroke: st) + } + for (i, lbl) in labels.enumerate() { + let (x, y) = pts.at(i) + circle((x, y), radius: 2.5pt, fill: if i == 0 { _ge-blue } else { black }) + let (dx, dy) = if lbl == "S" { (0, 0.45) } + else if lbl == "A" { (-0.35, -0.3) } + else if lbl == "B" { (0.35, -0.3) } + else if lbl == "C" { (0.3, 0.3) } + else { (-0.3, 0.3) } + content((x + dx, y + dy), text(size: 10pt, weight: "bold")[#lbl]) + } + }) +} + +// ── Lăng trụ tam giác ABC.A'B'C' ──────────────────────── +#let lang-tru-abc( + A: (-3, 0), B: (3, 0), C: (0, -2), + A2: (-3, 5), B2: (3, 5), C2: (0, 3), + labels: ("A", "B", "C", "A'", "B'", "C'"), + hidden: (), // "AA'" hoặc "AB" để đánh dấu nét đứt + scale: _ge-scale, +) = { + cetz.canvas(length: scale, { + import cetz.draw: * + let pts = (A, B, C, A2, B2, C2) + let edges = ((0, 1), (1, 2), (2, 0), (3, 4), (4, 5), (5, 3), (0, 3), (1, 4), (2, 5)) + for e in edges { + let (i, j) = e + let lbl = labels.at(i) + labels.at(j) + let rev = labels.at(j) + labels.at(i) + let st = if hidden.contains(lbl) or hidden.contains(rev) { + (paint: _ge-gray, thickness: 0.7pt, dash: "dashed") + } else { + 1.1pt + black + } + line(pts.at(i), pts.at(j), stroke: st) + } + for (i, lbl) in labels.enumerate() { + let (x, y) = pts.at(i) + circle((x, y), radius: 2.5pt, fill: black) + let (dx, dy) = if lbl == "A" or lbl == "A'" { (-0.35, -0.3) } + else if lbl == "B" or lbl == "B'" { (0.35, -0.3) } + else { (0, 0.35) } + content((x + dx, y + dy), text(size: 10pt, weight: "bold")[#lbl]) + } + }) +} + +// ═══════════════════════════════════════════════════════════ +// ĐƯỜNG TRÒN & MARKERS +// ═══════════════════════════════════════════════════════════ + +// ── Đường tròn tâm O, bán kính R ──────────────────────── +#let circle-desc( + center: (0, 0), + radius: 2, + label: "O", + scale: _ge-scale, +) = { + cetz.canvas(length: scale, { + import cetz.draw: * + circle(center, radius: radius, stroke: 1.2pt + black, fill: none) + circle(center, radius: 2.8pt, fill: black) + content((center.at(0) - 0.3, center.at(1) - 0.35), text(size: 10pt, weight: "bold")[#label]) + // Vẽ 2 đường kính nét mảnh + line( + (center.at(0) - radius, center.at(1)), + (center.at(0) + radius, center.at(1)), + stroke: (paint: _ge-gray, thickness: 0.4pt, dash: "dotted"), + ) + line( + (center.at(0), center.at(1) - radius), + (center.at(0), center.at(1) + radius), + stroke: (paint: _ge-gray, thickness: 0.4pt, dash: "dotted"), + ) + }) +} + +// ── Đánh dấu góc ──────────────────────────────────────── +#let angle-mark(A, O, B, radius: 0.5, label: none, fill: rgb("#FFE0B2"), scale: _ge-scale) = { + cetz.canvas(length: scale, { + import cetz.draw: * + let (ax, ay) = A + let (ox, oy) = O + let (bx, by) = B + let angleA = calc.atan2(ay - oy, ax - ox) + let angleB = calc.atan2(by - oy, bx - ox) + let r = radius + arc(O, r, angleA, angleB, stroke: 0.7pt + _ge-red, fill: fill) + if label != none { + let mx = ox + r * 1.5 * calc.cos((angleA + angleB) / 2) + let my = oy + r * 1.5 * calc.sin((angleA + angleB) / 2) + content((mx, my), text(size: 8pt, fill: _ge-red)[#label]) + } + }) +} + +// ── Đường đứt nét giữa 2 điểm ────────────────────────── +#let dashed-seg(A, B, scale: _ge-scale) = { + cetz.canvas(length: scale, { + import cetz.draw: * + line(A, B, stroke: (paint: _ge-gray, thickness: 0.7pt, dash: "dashed")) + }) +} + +// ═══════════════════════════════════════════════════════════ +// TRỤC TOẠ ĐỘ & ĐỒ THỊ +// ═══════════════════════════════════════════════════════════ + +// ── Hệ trục Oxy ───────────────────────────────────────── +#let axis-xy( + xmin: -3, xmax: 3, ymin: -2, ymax: 4, + xlabel: "x", ylabel: "y", + xstep: 1, ystep: 1, + scale: 1cm, + ticks: true, +) = { + cetz.canvas(length: scale, { + import cetz.draw: * + // Trục + line((xmin, 0), (xmax, 0), mark: (end: ">"), stroke: 1.2pt + black) + line((0, ymin), (0, ymax), mark: (end: ">"), stroke: 1.2pt + black) + content((xmax - 0.4, 0.35), $#xlabel$) + content((0.35, ymax - 0.3), $#ylabel$) + content((-0.25, -0.25), $O$) + // Tick marks + if ticks { + for x in range(xmin + 1, xmax) { + if x != 0 { + line((x, -0.15), (x, 0.15), stroke: 0.5pt + _ge-gray) + content((x, -0.4), text(size: 7pt)[#x]) + } + } + for y in range(ymin + 1, ymax) { + if y != 0 { + line((-0.15, y), (0.15, y), stroke: 0.5pt + _ge-gray) + content((-0.5, y - 0.1), text(size: 7pt)[#y]) + } + } + } + }) +} + +// ── Đồ thị hàm số (plot từ danh sách điểm) ────────────── +#let plot( + points, + xmin: auto, xmax: auto, ymin: auto, ymax: auto, + stroke-color: _ge-blue, + stroke-width: 1.3pt, + scale: 1cm, +) = { + cetz.canvas(length: scale, { + import cetz.draw: * + if xmin != auto and xmax != auto and ymin != auto and ymax != auto { + line((xmin, 0), (xmax, 0), mark: (end: ">"), stroke: 1pt + black) + line((0, ymin), (0, ymax), mark: (end: ">"), stroke: 1pt + black) + content((xmax - 0.4, 0.35), $x$) + content((0.35, ymax - 0.3), $y$) + content((-0.25, -0.25), $O$) + } + line(..points, stroke: stroke-width + stroke-color) + }) +} + +// ── Parabol y = ax² + bx + c ──────────────────────────── +#let parabola( + a: 1, b: 0, c: 0, + xmin: -3, xmax: 3, + ymin: auto, ymax: auto, + npts: 80, + accent: _ge-blue, + show-vertex: false, + show-roots: false, + scale: 1cm, +) = { + cetz.canvas(length: scale, { + import cetz.draw: * + let dx = (xmax - xmin) / npts + let pts = () + let yvals = () + for i in range(npts + 1) { + let x = xmin + i * dx + let y = a * x * x + b * x + c + pts.push((x, y)) + yvals.push(y) + } + let mymin = if ymin == auto { calc.min(..yvals) - 0.5 } else { ymin } + let mymax = if ymax == auto { calc.max(..yvals) + 0.5 } else { ymax } + line((xmin, 0), (xmax, 0), mark: (end: ">"), stroke: 1pt + black) + line((0, mymin), (0, mymax), mark: (end: ">"), stroke: 1pt + black) + content((xmax - 0.4, 0.35), $x$) + content((0.35, mymax - 0.3), $y$) + content((-0.25, -0.25), $O$) + line(..pts, stroke: 1.2pt + accent) + // Đỉnh + if show-vertex { + let vx = -b / (2 * a) + let vy = a * vx * vx + b * vx + c + circle((vx, vy), radius: 3pt, fill: _ge-red) + content((vx + 0.25, vy + 0.3), text(size: 8pt, fill: _ge-red)[đỉnh]) + } + // Nghiệm + if show-roots { + let delta = b * b - 4 * a * c + if delta >= 0 { + let sd = calc.sqrt(delta) + let r1 = (-b - sd) / (2 * a) + let r2 = (-b + sd) / (2 * a) + circle((r1, 0), radius: 3pt, fill: _ge-green) + circle((r2, 0), radius: 3pt, fill: _ge-green) + } + } + }) +} + +// ── Đường thẳng qua 2 điểm ───────────────────────────── +#let line-through(A, B, accent: _ge-blue, scale: 1cm) = { + cetz.canvas(length: scale, { + import cetz.draw: * + let xmin = calc.min(A.at(0), B.at(0)) - 1 + let xmax = calc.max(A.at(0), B.at(0)) + 1 + let ymin = calc.min(A.at(1), B.at(1)) - 1 + let ymax = calc.max(A.at(1), B.at(1)) + 1 + axis-xy(xmin: xmin, xmax: xmax, ymin: ymin, ymax: ymax, scale: scale) + line(A, B, stroke: 1.2pt + accent) + circle(A, radius: 3pt, fill: black) + circle(B, radius: 3pt, fill: black) + }) +} + +// ── Nửa đường tròn (trên / dưới) ─────────────────────── +#let semicircle( + center: (0, 0), radius: 2, + above: true, + accent: _ge-blue, + scale: 1cm, +) = { + cetz.canvas(length: scale, { + import cetz.draw: * + let (cx, cy) = center + let r = radius + let n = 60 + let pts = () + for i in range(n + 1) { + let angle = if above { calc.pi * i / n } else { calc.pi * (n - i) / n } + let x = cx + r * calc.cos(angle) + let y = if above { cy + r * calc.sin(angle) } else { cy - r * calc.sin(angle) } + pts.push((x, y)) + } + line(..pts, stroke: 1.2pt + accent) + line((cx - r, cy), (cx + r, cy), stroke: 0.5pt + _ge-gray + dotted) + circle((cx, cy), radius: 2.5pt, fill: black) + }) +} + +// ── Điểm ──────────────────────────────────────────────── +#let point(p, label: none, fill: black, radius: 2.5pt, scale: 0.5cm) = { + cetz.canvas(length: scale, { + import cetz.draw: * + circle(p, radius: radius, fill: fill) + if label != none { + let (x, y) = p + let (dx, dy) = (0.25, 0.25) + content((x + dx, y + dy), text(size: 8pt)[#label]) + } + }) +} + +// ── Đoạn thẳng ────────────────────────────────────────── +#let seg(A, B, stroke: 1.2pt + black, arrow: false, scale: 0.7cm) = { + cetz.canvas(length: scale, { + import cetz.draw: * + let mark = if arrow { (end: ">") } else { none } + line(A, B, stroke: stroke, mark: mark) + }) +} + +// ── Elip ngang ────────────────────────────────────────── +#let ellipse-h( + center: (0, 0), a: 3, b: 2, + accent: _ge-blue, + scale: 1cm, +) = { + cetz.canvas(length: scale, { + import cetz.draw: * + let (cx, cy) = center + let n = 100 + let pts = () + for i in range(n + 1) { + let t = 2 * calc.pi * i / n + pts.push((cx + a * calc.cos(t), cy + b * calc.sin(t))) + } + line(..pts, stroke: 1.2pt + accent) + circle((cx, cy), radius: 2.5pt, fill: black) + circle((cx + a, cy), radius: 2pt, fill: _ge-red) + circle((cx - a, cy), radius: 2pt, fill: _ge-red) + circle((cx, cy + b), radius: 2pt, fill: _ge-green) + circle((cx, cy - b), radius: 2pt, fill: _ge-green) + }) +} diff --git a/packages/preview/sang-math/1.0.0/lib.typ b/packages/preview/sang-math/1.0.0/lib.typ new file mode 100644 index 0000000000..551d634051 --- /dev/null +++ b/packages/preview/sang-math/1.0.0/lib.typ @@ -0,0 +1,24 @@ +// ================================================================ +// SANG-MATH v2.0 — Bộ macro Toán THPT chuẩn +// Entry point: #import "@local/sang-math:2.0.0": * +// hoặc: #import "@preview/sang-math:2.0.0": * (sau khi publish) +// ================================================================ + +// ── Core modules (v1) ──────────────────────────────────────────── +#import "bbt.typ": * // BBT, Bảng biến thiên, Bảng xét dấu +#import "sang-exam.typ": * // Trắc nghiệm, Tự luận, q-wrap... +#import "exam-templates.typ": * // Preset giao diện đề thi đẹp +#import "book-templates.typ": * // Preset giao diện sách, SGK, chuyên đề +#import "math-sym.typ": * // Ký hiệu toán tắt (vô cùng, tập hợp...) +#import "geometry.typ": * // Hình học phẳng/không gian CeTZ (legacy v1) + +// ── Core utilities (v2) ────────────────────────────────────────── +#import "core/math-utils.typ": * // linspace, lerp, rotate-2d, vec-* +#import "core/colors.typ": * // sm-blue, sm-red, sm-green... (palette chuẩn) + +// ── Geometry 2D (v2) ───────────────────────────────────────────── +#import "geometry-2d/conics.typ": * // draw-parabola, draw-ellipse, draw-hyperbola + +// ── Geometry 3D (v2) ───────────────────────────────────────────── +#import "geometry-3d/curves-3d.typ": * // draw-helix, draw-spring, helix-points +#import "geometry-3d/revolution.typ": * // draw-cylinder, draw-cone, draw-sphere diff --git a/packages/preview/sang-math/1.0.0/math-sym.typ b/packages/preview/sang-math/1.0.0/math-sym.typ new file mode 100644 index 0000000000..853b286581 --- /dev/null +++ b/packages/preview/sang-math/1.0.0/math-sym.typ @@ -0,0 +1,188 @@ +// ═══════════════════════════════════════════════════════════ +// MATH-SYM.TYP — Alias LaTeX → Typst cho AI/GV hay gõ nhầm +// Import: #import "math-sym.typ": * +// Dùng trong math mode: $A cap B$, $x geq 0$, $f rightarrow infty$ +// ═══════════════════════════════════════════════════════════ + +// ─── Tập hợp ──────────────────────────────────────────────── +#let cap = sym.inter // ∩ (LaTeX: \cap) +#let cup = sym.union // ∪ (LaTeX: \cup) +#let notin = sym.in.not // ∉ (LaTeX: +otin) +#let subseteq = sym.subset.eq // ⊆ (LaTeX: \subseteq) +#let supseteq = sym.supset.eq // ⊇ (LaTeX: \supseteq) +#let emptyset = sym.nothing // ∅ (LaTeX: \emptyset) +#let varnothing = sym.nothing // ∅ (LaTeX: \varnothing) +#let setminus = sym.backslash // ∖ (LaTeX: \setminus) + +// ─── So sánh & quan hệ ───────────────────────────────────── +#let leq = sym.lt.eq // ≤ (LaTeX: \leq / \le) +#let geq = sym.gt.eq // ≥ (LaTeX: \geq / \ge) +#let le = sym.lt.eq // ≤ alias ngắn +#let ge = sym.gt.eq // ≥ alias ngắn +#let neq = sym.eq.not // ≠ (LaTeX: +eq / +e) +#let ne = sym.eq.not // ≠ alias ngắn +#let approx = sym.approx // ≈ (LaTeX: \approx) +#let equiv = sym.equiv // ≡ (LaTeX: \equiv) +#let sim = sym.tilde // ~ (LaTeX: \sim) +#let simeq = sym.tilde.eq // ≃ (LaTeX: \simeq) +#let cong = sym.tilde.equiv // ≅ (LaTeX: \cong) +#let ll = sym.lt.double // ≪ (LaTeX: \ll) +#let gg = sym.gt.double // ≫ (LaTeX: \gg) +#let propto = sym.prop // ∝ (LaTeX: \propto) + +// ─── Mũi tên ──────────────────────────────────────────────── +#let rightarrow = sym.arrow.r // → (LaTeX: \rightarrow) +#let leftarrow = sym.arrow.l // ← (LaTeX: \leftarrow) +#let leftrightarrow = sym.arrow.l.r // ↔ (LaTeX: \leftrightarrow) +#let Rightarrow = sym.arrow.r.double // ⇒ (LaTeX: \Rightarrow) +#let Leftarrow = sym.arrow.l.double // ⇐ (LaTeX: \Leftarrow) +#let Leftrightarrow = sym.arrow.l.r.double // ⇔ (LaTeX: \Leftrightarrow) +#let uparrow = sym.arrow.t // ↑ (LaTeX: \uparrow) +#let downarrow = sym.arrow.b // ↓ (LaTeX: \downarrow) +#let to = sym.arrow.r // → (LaTeX: \to) +#let mapsto = sym.arrow.r.bar // ↦ (LaTeX: \mapsto) +#let hookrightarrow = sym.arrow.hook.r // ↪ (LaTeX: \hookrightarrow) +#let longrightarrow = sym.arrow.long.r // ⟶ (LaTeX: \longrightarrow) + +// ─── Phép tính ────────────────────────────────────────────── +#let pm = sym.plus.minus // ± (LaTeX: \pm) +#let mp = sym.minus.plus // ∓ (LaTeX: \mp) +#let cdot = sym.dot.c // · (LaTeX: \cdot) +#let times = sym.times // × (LaTeX: \times) +#let div = sym.div // ÷ (LaTeX: \div) +#let ast = sym.ast // ∗ (LaTeX: \ast) +#let circ = sym.degree // ° (LaTeX: \circ khi dùng cho độ: 90^\circ → $90^circ$) +#let compose = sym.circle.small // ∘ phép hợp hàm f∘g (nếu AI gõ \circ cho composition) +#let oplus = sym.plus.o // ⊕ (LaTeX: \oplus) +#let otimes = sym.times.o // ⊗ (LaTeX: \otimes) + +// ─── Logic & lượng từ ────────────────────────────────────── +#let forall = sym.forall // ∀ (LaTeX: \forall) +#let exists = sym.exists // ∃ (LaTeX: \exists) +#let nexists = sym.exists.not // ∄ (LaTeX: +exists) +#let neg = sym.not // ¬ (LaTeX: +eg) +#let land = sym.and // ∧ (LaTeX: \land) +#let lor = sym.or // ∨ (LaTeX: \lor) +#let therefore = sym.therefore // ∴ (LaTeX: \therefore) +#let because = sym.because // ∵ (LaTeX: \because) + +// ─── Tích phân & Giải tích ───────────────────────────────── +// Typst dùng `integral` trực tiếp trong math mode. +// Nếu AI gõ `int` thay vì `integral`: +#let int = sym.integral // ∫ (LaTeX: \int) +#let iint = sym.integral.double // ∬ (LaTeX: \iint) +#let iiint = sym.integral.triple // ∭ (LaTeX: \iiint) +#let oint = sym.integral.cont // ∮ (LaTeX: \oint) +#let partial = sym.partial // ∂ (LaTeX: \partial) +#let nabla = sym.nabla // ∇ (LaTeX: +abla) + +// ─── Vô cực & giới hạn ───────────────────────────────────── +#let infty = sym.infinity // ∞ (LaTeX: \infty) +#let infinity = sym.infinity // ∞ alias dài hơn + +// ─── Dấu chấm & dấu ba chấm ──────────────────────────────── +#let ldots = sym.dots.h // … (LaTeX: \ldots) +#let cdots = sym.dots.c // ⋯ (LaTeX: \cdots) +#let vdots = sym.dots.v // ⋮ (LaTeX: \vdots) + + +// ─── Hình học ─────────────────────────────────────────────── +#let perp = sym.perp // ⊥ (LaTeX: \perp) +#let parallel = sym.parallel // ∥ (LaTeX: \parallel) +#let angle = sym.angle // ∠ (LaTeX: \angle) +#let measuredangle = sym.angle.arc // ∡ (LaTeX: \measuredangle) +#let triangle = sym.triangle // △ (LaTeX: \triangle) +#let square = sym.square // □ (LaTeX: \square) +#let diamond = sym.diamond // ◇ (LaTeX: \diamond) + +// ─── Ký hiệu đặc biệt ────────────────────────────────────── +#let hbar = sym.planck // ℏ (LaTeX: \hbar) +#let ell = sym.ell // ℓ (LaTeX: \ell) +#let Re = sym.Re // ℜ (LaTeX: \Re) +#let Im = sym.Im // ℑ (LaTeX: \Im) +#let aleph = sym.aleph // ℵ (LaTeX: \aleph) +#let prime_sym = sym.prime // ′ dùng khi cần prime standalone + +// ─── Tổ hợp chập ──────────────────────────────────────────── +// Định dạng chuẩn Việt Nam: C_n^k, A_n^k, P_n (ưu tiên user memory) +#let C(n, k) = math.attach(math.op("C"), b: n, t: k) // Tổ hợp +#let A(n, k) = math.attach(math.op("A"), b: n, t: k) // Chỉnh hợp +#let P(n) = math.attach(math.op("P"), b: n) // Hoán vị +#let binom(n, k) = math.binom(n, k) // Giữ lại nếu lỡ cần dùng kiểu LaTeX cũ + +// ─── Hàm lượng giác ngược ─────────────────────────────────── +// Typst đã có: sin, cos, tan, ln, log, exp, lim, max, min, sup, inf, det, ... +// Thêm các hàm hay thiếu: +#let arcsin = math.op("arcsin") // arcsin (LaTeX: \arcsin) +#let arccos = math.op("arccos") // arccos (LaTeX: \arccos) +#let arctan = math.op("arctan") // arctan (LaTeX: \arctan) +#let arccot = math.op("arccot") // arccot +#let cot = math.op("cot") // cot (LaTeX: \cot) +#let csc = math.op("csc") // csc (LaTeX: \csc) +#let sec = math.op("sec") // sec (LaTeX: \sec) +#let sgn = math.op("sgn") // sgn (LaTeX: \operatorname{sgn}) + +// ─── Vector & accent ──────────────────────────────────────── +// LaTeX: \vec{v} → Typst: $arrow(v)$ hoặc $vec(v)$ +// LaTeX: \overline → Typst: $overline(x)$ +// LaTeX: \hat{x} → Typst: $hat(x)$ +// Alias để tránh nhầm: +#let overrightarrow(x) = math.arrow(x) // \overrightarrow{AB} → overrightarrow(A B) +#let vec(x) = math.arrow(x) // \vec{v} → vec(v) (override nếu cần) +#let prod = sym.product // \prod → prod +#let pmat = math.mat // \pmatrix / \pmat → mat +#let Tr = math.op("Tr") // Trace of a matrix + + + + +// ─── Phân số & căn ────────────────────────────────────────── +// LaTeX: \frac{a}{b} → Typst: $a/b$ hoặc $frac(a, b)$ +// LaTeX: \dfrac → sang-exam: $tfrac()$ hoặc tự sang-setup +// LaTeX: \sqrt[n]{x} → Typst: $root(n, x)$ +// Alias: +#let dfrac(a, b) = math.display(math.frac(a, b)) // \dfrac{a}{b} → dfrac(a, b) +#let tfrac-tex(a, b) = math.frac(a, b) // \tfrac{a}{b} — dùng tfrac() từ sang-exam + +// ─── Tổng kết ký hiệu thường nhầm ─────────────────────────── +// +// LaTeX Typst thuần Alias file này +// ────────────── ───────────── ────────────── +// \cap sect cap +// \cup union cup +// \leq / \le lt.eq leq / le +// \geq / \ge gt.eq geq / ge +// +eq / +e eq.not neq / ne +// \pm plus.minus pm +// \cdot dot.c cdot +// \infty infinity infty +// \rightarrow arrow.r rightarrow / to +// \Rightarrow arrow.r.double Rightarrow +// \Leftrightarrow arrow.l.r.double Leftrightarrow +// \forall forall forall +// \exists exists exists +// \therefore therefore therefore +// \partial partial partial +// \int integral int +// \perp perp perp +// \parallel parallel parallel +// \subset subset (đã có sẵn) +// \subseteq subset.eq subseteq +// \emptyset nothing emptyset +// \in in (đã có sẵn) +// +otin in.not notin +// \cot — cot (math.op) +// \arcsin — arcsin (math.op) +// \vec{v} arrow(v) vec(v) / overrightarrow(v) + +#let boxed(x) = box(stroke: 0.5pt, inset: 3pt, outset: 0pt)[#x] + diff --git a/packages/preview/sang-math/1.0.0/sang-exam.typ b/packages/preview/sang-math/1.0.0/sang-exam.typ new file mode 100644 index 0000000000..2d5e958812 --- /dev/null +++ b/packages/preview/sang-math/1.0.0/sang-exam.typ @@ -0,0 +1,1646 @@ +// ========================================================= +// SANG-EXAM.TYP v6.0 — Engine thi THPT chuẩn ex_test.sty +// ========================================================= + +// ── Beamer-mode flag (set by sang-beamer.typ to suppress print-only elements) ── +#let _beamer-mode = state("_beamer-mode", false) +#let set-beamer-mode() = { _beamer-mode.update(true) } + +// ── Font viết tay toàn cục ──────────────────────────────── +// Được set bởi thpt-school-exam(handwriting-font: ...) +// Dùng #hw[text] ở bất kỳ đâu trong file .typ +#let _hw-font = state("_hw-font", none) +#let _hw-size = state("_hw-size", 1em) +#let hw(it) = context { + let f = _hw-font.get() + if f != none { + text(font: f, size: _hw-size.get())[#it] + } else { + text(style: "italic")[#it] + } +} + +// ── Màu ────────────────────────────────────────────────── +#let palette = ( + ink: black, + muted: rgb("#555"), + border: rgb("#bbb"), + accent: rgb("#0057b8"), + correct: rgb("#1a7a2e"), + wrong: rgb("#cc2200"), + sol-bg: rgb("#f0f6ff"), +) +#let classic = ( + blue: rgb("#0057b8"), + emerald: rgb("#1a7a2e"), + crimson: rgb("#cc2200"), + ink: black, +) + +// ── sang-setup: wrapper đặt show rules ─────────────────── +// Dùng: #show: sang-setup +// #show: sang-setup.with(math-color: accent) +#let sang-setup(body, math-color: black) = { + show math.equation.where(block: false): math.display + show math.frac: math.display + show math.equation: set text(fill: math-color) + + // Tự động chuyển C, A, P (những chữ số gán sub/sup) thành chữ đứng để in đúng C_n^k + show math.attach: it => { + let f = it.base.fields() + if "text" in f and f.text in ("C", "A", "P") { + return math.attach(math.upright(f.text), t: it.t, b: it.b, tl: it.tl, bl: it.bl, tr: it.tr, br: it.br) + } + it + } + + show list: it => { + let any-frac = it.children.any(item => { + let r = repr(item.body) + r.contains("frac") and not r.contains("list(") + }) + if any-frac { + { + set list(spacing: 2.8em) + it + } + } else { it } + } + body +} + +// ── Helpers ─────────────────────────────────────────────── +#let True(body) = (body: body, correct: true) +#let False(body) = (body: body, correct: false) + +// ═══════════════════════════════════════════════════════════ +// HỘP SƯ PHẠM — 5 kiểu hình thức khác nhau +// ═══════════════════════════════════════════════════════════ +// +// ① BANNER — thanh tiêu đề màu trên cùng (ppgiai, lythuyet) +// ② ALERT — viền trái 6pt + all-around thin border (luuy) +// ③ PILL — full border 2pt + góc tròn 10pt, tiêu đề inline (meo) +// ④ DASHED — viền chấm-chấm + dải phân cách (nhanxet, note) +// ⑤ THEOREM — đường kẻ trên 2.5pt + dưới 1pt (dn, dl, tc, bode) + +// ① BANNER ───────────────────────────────────────────────── +#let _panel-banner(body, title, hdr-fill, body-fill) = block( + width: 100%, + below: 0.85em, + radius: 5pt, + clip: true, +)[ + #block(width: 100%, fill: hdr-fill, inset: (x: 12pt, y: 6pt))[ + #text(fill: white, weight: "bold", size: 10.5pt)[#title] + ] + #block(width: 100%, fill: body-fill, inset: (x: 13pt, y: 9pt))[ + #set text(fill: luma(30)) + #body + ] +] + +// ② ALERT ────────────────────────────────────────────────── +#let _panel-alert(body, title, accent, fill) = block( + width: 100%, + below: 0.85em, + stroke: (left: 6pt + accent, rest: 0.8pt + accent.lighten(50%)), + inset: (left: 14pt, right: 12pt, top: 8pt, bottom: 8pt), + radius: (right: 5pt), + fill: fill, +)[ + #text(weight: "bold", fill: accent)[#title] + #v(0.25em) + #set text(fill: luma(30)) + #body +] + +// ③ PILL ─────────────────────────────────────────────────── +#let _panel-pill(body, title, accent, fill) = block( + width: 100%, + below: 0.85em, + stroke: 2pt + accent, + inset: (x: 14pt, y: 9pt), + radius: 10pt, + fill: fill, +)[ + #set text(fill: luma(30)) + #text(weight: "bold", fill: accent)[#title] #h(3pt) #body +] + +// ④ DASHED ───────────────────────────────────────────────── +#let _panel-dashed(body, title, accent, fill) = block( + width: 100%, + below: 0.85em, + stroke: (paint: accent, thickness: 1pt, dash: "dashed"), + inset: (x: 12pt, y: 8pt), + radius: 4pt, + fill: fill, +)[ + #text(weight: "bold", fill: accent)[#title] + #v(0.15em) + #line(length: 100%, stroke: (paint: accent, thickness: 0.5pt, dash: "dotted")) + #v(0.2em) + #set text(fill: luma(30)) + #body +] + +// ⑤ THEOREM ──────────────────────────────────────────────── +#let _panel-theorem(body, title, accent, fill) = block( + width: 100%, + below: 0.85em, +)[ + #line(length: 100%, stroke: 2.5pt + accent) + #block( + width: 100%, + fill: fill, + inset: (x: 13pt, top: 7pt, bottom: 9pt), + )[ + #set text(fill: luma(30)) + #text(weight: "bold", fill: accent, style: "italic")[#title.] #h(4pt) #body + ] + #line(length: 100%, stroke: 1pt + accent.lighten(35%)) +] + +// ── Hộp lời giải ────────────────────────────────────────── + +// 💡 Phương pháp giải — Banner xanh lá +#let ppgiai(body, title: [💡 Phương pháp giải], ..args) = _panel-banner( + body, + title, + rgb("#1a7a2e"), + rgb("#eef6ed"), +) + +// ⚠ Lưu ý — Alert cam, viền trái 6pt +#let luuy(body, title: [⚠ Lưu ý], ..args) = _panel-alert( + body, + title, + rgb("#e65100"), + rgb("#fff4e5"), +) + +// 🚀 Mẹo nhanh — Pill tròn hồng, tiêu đề inline +#let meo(body, title: [🚀 Mẹo nhanh], ..args) = _panel-pill( + body, + title, + rgb("#c2185b"), + rgb("#fce4ec"), +) + +// 📝 Nhận xét — Dashed tím, có gạch phân cách +#let nhanxet(body, title: [📝 Nhận xét], ..args) = _panel-dashed( + body, + title, + rgb("#6b21a8"), + rgb("#faf5ff"), +) + +#let giainhanh(body, title: [🚀 Mẹo nhanh], ..args) = meo(body, title: title) + +// 📖 Lý thuyết — Banner xanh dương +#let lythuyet(body, title: [📖 Lý thuyết], ..args) = _panel-banner( + body, + title, + rgb("#1553a0"), + rgb("#eef4ff"), +) + +// Ghi chú — Dashed teal +#let note(body, title: [Ghi chú], ..args) = _panel-dashed( + body, + title, + rgb("#0f766e"), + rgb("#f0fdfa"), +) + +// ── Hộp học thuật — Kiểu Theorem (⑤) ───────────────────── + +// Định nghĩa — đường kẻ xanh dương +#let dn(body, title: [Định nghĩa], ..args) = _panel-theorem( + body, + title, + rgb("#1d4ed8"), + rgb("#eff6ff"), +) + +// Định lý — đường kẻ đỏ đậm +#let dl(body, title: [Định lý], ..args) = _panel-theorem( + body, + title, + rgb("#b91c1c"), + rgb("#fff1f2"), +) + +// Tính chất — đường kẻ xanh lá +#let tc(body, title: [Tính chất], ..args) = _panel-theorem( + body, + title, + rgb("#15803d"), + rgb("#f0fdf4"), +) + +// Bổ đề — đường kẻ cam đậm +#let bode(body, title: [Bổ đề], ..args) = _panel-theorem( + body, + title, + rgb("#c2410c"), + rgb("#fff7ed"), +) + +// ── Bộ đếm & state ──────────────────────────────────────── +#let _q-cnt = counter("se-q") +#let _global-q-cnt = counter("se-g-q") +#let _part-cnt = counter("se-p-cnt") +#let _mcq-meta = state("se-mcq", ()) +#let _tf-meta = state("se-tf", ()) +#let _sh-meta = state("se-sh", ()) +#let _tl-meta = state("se-tl", ()) + +// - #resetexamstate() -> xoá đáp án đã gom, reset số câu và số phần về đầu đề mới +#let resetexamstate(question-start: 1, part-start: 1) = { + let current-question = if question-start > 0 { question-start - 1 } else { 0 } + let current-part = if part-start > 0 { part-start - 1 } else { 0 } + [ + #_q-cnt.update(current-question) + #_global-q-cnt.update(current-question) + #_part-cnt.update(current-part) + #_mcq-meta.update(_ => ()) + #_tf-meta.update(_ => ()) + #_sh-meta.update(_ => ()) + #_tl-meta.update(_ => ()) + #metadata((kind: "question", current: current-question)) + ] +} + +#let _q-marker = +#let _q-reset-marker = + +#let _resolve-loigiai(loigiai, args) = { + if loigiai != none { loigiai } else { args.named().at("solution", default: none) } +} + +// Helper: scale fig so its rendered width = fig-width × available width, then center. +// Scales geometry + labels proportionally (uniform scale). To keep label size fixed +// while changing figure geometry size, adjust `length:` inside cetz.canvas() instead. +#let _fig-scaled-center(fig, fig-width) = layout(size => context { + let sz = measure(fig) + if sz.width > 0pt { + let target-w = size.width * fig-width + let r = target-w / sz.width + align(center, scale(fig, x: r * 100%, y: r * 100%, origin: top + center, reflow: true)) + } else { + align(center, fig) + } +}) + +#let _question-frame( + body, + below: 1em, + boxed: false, + fill: white, + stroke: 0.6pt + palette.border, + inset: (x: 10pt, y: 8pt), + radius: 4pt, +) = { + if boxed { + block(width: 100%, below: below, fill: fill, stroke: stroke, inset: inset, radius: radius)[#body] + } else { + block(width: 100%, below: below)[#body] + } +} + +#let _next-question-num(num: auto) = { + let resets = query(selector(_q-reset-marker).before(here())) + let resolved = if num == auto { + if resets.len() == 0 { + query(selector(_q-marker).before(here())).len() + 1 + } else { + let reset = resets.last() + let base = reset.value.current + let seen = query(selector(_q-marker).after(reset.location()).before(here())).len() + base + seen + 1 + } + } else { + num + } + ( + num: resolved, + markers: [ + #if num != auto { + [#metadata((kind: "question", current: num - 1)) ] + } + #metadata("question") + #counter("se-step").update(0) + ], + ) +} + +// ── Điều khiển bộ đếm ──────────────────────────────────── +// - #resetcau() -> câu kế tiếp là Câu 1 +// - #setcau(13) -> câu kế tiếp là Câu 13 +// - #resetphan() -> phần kế tiếp là PHẦN I +// - #setphan(3) -> phần kế tiếp là PHẦN III +// - #exam-part(..., reset-counter: true) -> reset số câu về 1 ngay đầu phần đó +#let setcounter(env, start) = { + let current = if start > 0 { start - 1 } else { 0 } + let name = if type(env) == str { lower(env) } else { env } + if ("cau", "question", "tn", "ds", "tln", "tl").contains(name) { + [ + #metadata((kind: "question", current: current)) + ] + } else if ("part", "phan").contains(name) { + [#_part-cnt.update(current)] + } else { + none + } +} + +#let resetcounter(env, start: 1) = setcounter(env, start) +#let setcau(start) = setcounter("cau", start) +#let resetcau(start: 1) = resetcounter("cau", start: start) +#let setphan(start) = setcounter("part", start) +#let resetphan(start: 1) = resetcounter("part", start: start) + +// ── Kẻ dòng chấm cho HS ─────────────────────────────────── +#let draw-lines(n) = { + v(0.2em) + for i in range(n) { + v(0.6em) + line(length: 100%, stroke: (paint: rgb("#b0b0b0"), thickness: 0.6pt, dash: "dotted")) + v(0.8em) + } +} + +// ── Ô ly và điền khuyết ────────────────────────────────── +#let dien-khuyet(lines: 4) = { + v(0.5em) + for _ in range(lines) { + box(width: 100%, stroke: (bottom: 0.5pt + luma(150), rest: none), height: 1.5em) + v(0em) + } +} + +#let o-ly(rows: 5) = { + v(0.5em) + let cell-content = [] + let grid-content = grid( + columns: (1.5em,) * 25, + rows: (1.5em,) * rows, + stroke: 0.5pt + luma(200), + ..([#cell-content],) * 25 * rows + ) + align(center, grid-content) +} + +// ───────────────────────────────────────────────────────── +// CÁC BƯỚC GIẢI CHI TIẾT (có tự động đổi màu) +// ───────────────────────────────────────────────────────── +#let _step-colors = ( + rgb("#1565c0"), + rgb("#e65100"), + rgb("#2e7d32"), + rgb("#6a1b9a"), + rgb("#00838f"), + rgb("#c62828"), +) +#let _step-cnt = counter("se-step") +#let _step-reveal-config = state("se-step-reveal-config", (before_nonfirst: none)) +#let configure-step-reveal(before_nonfirst: none) = [ + #_step-reveal-config.update((before_nonfirst: before_nonfirst)) +] +#let step(body, color: auto, before_nonfirst: auto) = { + _step-cnt.step() + context { + let n = _step-cnt.get().first() + let c = _step-colors.at(calc.rem(n - 1, _step-colors.len())) + let text-color = if color != auto { color } else { c.darken(10%) } + let border-color = if color != auto { color } else { c } + let reveal = if before_nonfirst != auto { + before_nonfirst + } else if n > 1 { + _step-reveal-config.get().at("before_nonfirst", default: none) + } else { + none + } + let step-block = block( + width: 100%, + stroke: (left: 1.5pt + border-color), + inset: (left: 8pt, right: 0pt, top: 1.5pt, bottom: 1.5pt), + below: 0.3em, + above: 0.3em, + )[ + #text(fill: text-color, weight: "bold")[Bước #n.] #h(0.3em) #body + ] + if reveal == none { + step-block + } else { + [#reveal #step-block] + } + } +} +#let reset-step() = [#_step-cnt.update(0)] + +// ── Lời giải block ──────────────────────────────────────── +#let _sol(s, a) = block( + width: 100%, + fill: palette.sol-bg, + stroke: (left: 3pt + a), + inset: (left: 10pt, right: 8pt, top: 6pt, bottom: 6pt), + radius: (right: 4pt), +)[#reset-step()#text(weight: "bold", fill: a)[Lời giải.] #s] + +#let _q-label(prefix, num, accent, style: "plain") = { + if style == "pill" { + box( + fill: accent.lighten(82%), + stroke: 0.7pt + accent.lighten(35%), + inset: (x: 7pt, y: 2pt), + radius: 999pt, + text(weight: "bold", fill: accent)[#prefix #num.] + ) + h(4pt) + } else if style == "badge" { + box( + fill: accent, + inset: (x: 7pt, y: 2.5pt), + radius: 4pt, + text(weight: "bold", fill: white)[#prefix #num.] + ) + h(5pt) + } else if style == "solid-pill" { + box( + fill: accent, + inset: (x: 8pt, y: 2.3pt), + radius: 999pt, + text(weight: "bold", fill: white)[#prefix #num] + ) + text(weight: "bold", fill: accent)[. ] + } else if style == "underline" { + box( + stroke: (bottom: 1.1pt + accent), + inset: (bottom: 1.5pt), + text(weight: "bold", fill: accent)[#prefix #num] + ) + text(weight: "bold", fill: accent)[. ] + } else if style == "ribbon" { + box(width: 3pt, height: 1.25em, fill: accent, radius: 1pt) + h(4pt) + text(weight: "bold", fill: accent)[#prefix #num.] + h(3pt) + } else if style == "flag" { + box( + fill: accent, + inset: (left: 7pt, right: 10pt, y: 2pt), + radius: (left: 3pt), + text(weight: "bold", fill: white)[#prefix #num] + ) + text(weight: "bold", fill: accent)[. ] + } else if style == "spark" { + box( + stroke: 0.8pt + accent, + fill: accent.lighten(88%), + inset: (x: 6pt, y: 2pt), + radius: 3pt, + text(weight: "bold", fill: accent)[#prefix #num] + ) + text(weight: "bold", fill: accent)[. ] + } else { + text(weight: "bold", fill: accent)[#prefix #num. ] + } +} + +#let _option-label(label, col, style: "plain") = { + let content = text(size: 0.86em, weight: "bold", fill: col)[#label] + let white-content = text(size: 0.86em, weight: "bold", fill: white)[#label] + let poly-label(vertices, fill: col.lighten(88%), stroke: 0.75pt + col, label-body: content, angle: 0deg) = box(width: 1.45em, height: 1.45em)[ + #place(center + horizon)[ + #rotate(angle)[ + #polygon.regular( + vertices: vertices, + size: 1.35em, + fill: fill, + stroke: stroke, + ) + ] + ] + #place(center + horizon)[#label-body] + ] + if style == "circled" or style == "circle" { + box(stroke: 0.75pt + col, radius: 50%, width: 1.35em, height: 1.35em, align(center + horizon)[#content]) + } else if style == "double-circle" { + box(stroke: 1.25pt + col, radius: 50%, inset: 1.1pt)[ + #box(stroke: 0.45pt + col.lighten(35%), radius: 50%, width: 1.2em, height: 1.2em, align(center + horizon)[#content]) + ] + } else if style == "solid-circle" { + box(fill: col, radius: 50%, width: 1.35em, height: 1.35em, align(center + horizon)[#text(size: 0.86em, weight: "bold", fill: white)[#label]]) + } else if style == "square" { + box(stroke: 0.75pt + col, width: 1.28em, height: 1.28em, align(center + horizon)[#content]) + } else if style == "solid-square" { + box(fill: col, width: 1.28em, height: 1.28em, align(center + horizon)[#white-content]) + } else if style == "rounded-square" { + box(stroke: 0.75pt + col, fill: col.lighten(90%), radius: 3pt, width: 1.35em, height: 1.35em, align(center + horizon)[#content]) + } else if style == "diamond" { + poly-label(4, angle: 45deg) + } else if style == "solid-diamond" { + poly-label(4, fill: col, stroke: 0.75pt + col, label-body: white-content, angle: 45deg) + } else if style == "triangle" { + poly-label(3) + } else if style == "solid-triangle" { + poly-label(3, fill: col, stroke: 0.75pt + col, label-body: white-content) + } else if style == "pentagon" { + poly-label(5) + } else if style == "solid-pentagon" { + poly-label(5, fill: col, stroke: 0.75pt + col, label-body: white-content) + } else if style == "hexagon" { + poly-label(6) + } else if style == "solid-hexagon" { + poly-label(6, fill: col, stroke: 0.75pt + col, label-body: white-content) + } else if style == "badge" { + box(fill: col.lighten(85%), stroke: 0.55pt + col, inset: (x: 5pt, y: 1.2pt), radius: 3pt)[#content] + } else { + text(weight: "bold", fill: col)[#label.] + } +} + +#let _draft-box(lines: 5, title: [Nháp], accent: palette.accent) = block( + width: 100%, + fill: luma(252), + stroke: (paint: accent.lighten(45%), thickness: 0.7pt, dash: "dotted"), + inset: (x: 7pt, y: 6pt), + radius: 4pt, +)[ + #text(size: 8.5pt, weight: "bold", fill: accent)[#title] + #v(0.35em) + #for _ in range(lines) { + line(length: 100%, stroke: 0.35pt + luma(210)) + v(0.72em) + } +] + +#let _maybe-draft(body, draft: false, draft-width: 30%, draft-lines: 5, draft-title: [Nháp], accent: palette.accent) = { + if draft { + grid( + columns: (1fr, draft-width), + column-gutter: 10pt, + align: (left + top, left + top), + body, + _draft-box(lines: draft-lines, title: draft-title, accent: accent), + ) + } else { + body + } +} + +// ───────────────────────────────────────────────────────── +// MCQ +// Auto-col: đo text width vs 0.25L / 0.50L (chuẩn ex_test) +// fig: / fig-pos: "right"|"left"|"center" / fig-width: / cols: 0|1|2|4 +// opt-fig: true → 4 options là hình vẽ (cetz canvas…), bỏ qua measure() +// opt-fig-cols: → số cột khi options là hình (mặc định 2) +// ───────────────────────────────────────────────────────── +#let mcq( + stem, + options, + correct: (), + loigiai: none, + mode: "dethi", + accent: palette.accent, + fig: none, + fig-pos: "right", + fig-width: 35%, + cols: 0, + row-gutter: auto, + opt-fig: false, + opt-fig-cols: 2, + opt-style: "plain", + opt-label-color: auto, + lines: 0, + num: auto, + prefix: "Câu", + q-label-style: "plain", + tags: (), + show-tags: true, + draft: false, + draft-width: 30%, + draft-lines: 5, + draft-title: [Nháp], + boxed: false, + box-fill: white, + box-stroke: 0.6pt + palette.border, + box-inset: (x: 10pt, y: 8pt), + box-radius: 4pt, + ..args, +) = context { + let loigiai = _resolve-loigiai(loigiai, args) + let q-state = _next-question-num(num: num) + let num = q-state.num + let labels = ("A", "B", "C", "D", "E", "F") + + let render-label(i, col) = { + let label-col = if opt-label-color == auto { col } else { opt-label-color } + _option-label(labels.at(i), label-col, style: opt-style) + } + + // Tìm đáp án đúng & lưu state + let ai = -1 + let idx = 0 + for opt in options { + let ok = if type(opt) == dictionary { opt.at("correct", default: false) } else { correct.contains(idx + 1) } + if ok { ai = idx } + idx += 1 + } + // Mảng text và correct cho từng option + let opt-texts = options.map(o => if type(o) == dictionary { o.body } else { o }) + let opt-oks = options + .enumerate() + .map(((i, o)) => if type(o) == dictionary { o.at("correct", default: false) } else { correct.contains(i + 1) }) + + // ── Phát hiện options là hình ──────────────────────────── + // cetz canvas dùng layout() bên trong → measure() sẽ crash. + // Kiểm tra repr: nếu bất kỳ option nào chứa "layout(" thì coi là hình. + // Người dùng cũng có thể đặt opt-fig: true để ép chế độ hình. + let _is-fig = opt-fig or opt-texts.any(t => repr(t).contains("layout(")) + + let em-sz = text.size + let label-width = 1.6 * em-sz + let label-gap = 0.3 * em-sz + let option-top-gap = 0.16em + let option-indent = if _is-fig { 0pt } else { 1.1 * em-sz } + + // Đo inline width và height — BỎ QUA nếu options là hình + let mw = 0pt + let max-inline-h = 0pt + let has-frac = false + if not _is-fig { + for t in opt-texts { + let sz = measure(box[#t]) + if sz.width > mw { mw = sz.width } + if sz.height > max-inline-h { max-inline-h = sz.height } + let rt = repr(t) + if ( + rt.contains("frac(") + or rt.contains("binom(") + or rt.contains("integral(") + or rt.contains("sum(") + or rt.contains("limits(") + or rt.contains("mat(") + or rt.contains("cases(") + or rt.contains("vec(") + ) { + has-frac = true + } + } + } + let has-tall-math = max-inline-h > 1.45 * em-sz or has-frac + let option-leading = if has-tall-math { 1.8em } else { 0.95em } + + // ── Render options ────────────────────────────────────── + let opts-r = if _is-fig { + // Chế độ hình: thẻ card 2×2, label ở góc trên-trái, hình căn giữa + let is_arr = type(cols) == array + let nc = if is_arr { cols.len() } else if cols != 0 { cols } else { opt-fig-cols } + let grid-cols = if is_arr { cols } else { nc } + grid( + columns: grid-cols, + row-gutter: 8pt, + column-gutter: 8pt, + align: left + top, + ..opt-texts + .enumerate() + .map(((i, t)) => { + let hi = (mode == "loigiai" or mode == "solcolor") and opt-oks.at(i) + let col = if hi { rgb("#cc2200") } else { black } + let card-stroke = if hi { + (paint: col, thickness: 2pt) + } else { + 0.7pt + luma(180) + } + block( + width: 100%, + stroke: card-stroke, + radius: 5pt, + inset: (x: 8pt, top: 6pt, bottom: 8pt), + fill: if hi { rgb("#fff5f5") } else { white }, + )[ + #render-label(i, col) + #v(0.3em) + #align(center)[#t] + ] + }) + ) + } else { + // Chế độ văn bản / toán — logic cũ + layout(sz => { + let lw = sz.width + let nc = if cols != 0 { + cols + } else { + let chosen = 1 + for candidate in (4, 2) { + if chosen == 1 { + let gutter = if candidate == 4 { 11pt } else { 13pt } + let cell-width = (lw - gutter * (candidate - 1)) / candidate + let text-width = cell-width - label-width - label-gap + if text-width > 0pt and mw <= text-width * 0.95 { + chosen = candidate + } + } + } + chosen + } + let column-gutter = if nc == 1 { 0pt } else if nc == 4 { 11pt } else { 13pt } + let calc-row-gutter = if row-gutter != auto { row-gutter } else if nc == 4 { 0.45em } else { + option-leading + } + let decorated-labels = ( + "circle", + "circled", + "double-circle", + "solid-circle", + "square", + "solid-square", + "rounded-square", + "diamond", + "solid-diamond", + "triangle", + "solid-triangle", + "pentagon", + "solid-pentagon", + "hexagon", + "solid-hexagon", + "badge", + ) + let option-align = if decorated-labels.contains(opt-style) { + (left + horizon, left + horizon) + } else { + (left + top, left + top) + } + grid( + columns: if type(cols) == array { cols } else { nc }, + row-gutter: calc-row-gutter, + column-gutter: column-gutter, + align: left + top, + ..opt-texts + .enumerate() + .map(((i, t)) => { + let hi = (mode == "loigiai" or mode == "solcolor") and opt-oks.at(i) + let col = if hi { rgb("#cc2200") } else { black } + grid( + columns: (label-width, 1fr), + column-gutter: label-gap, + align: option-align, + render-label(i, col), + [ + #set par(justify: false, leading: option-leading) + #text(fill: col, weight: if hi { "bold" } else { "regular" })[#t] + ], + ) + }) + ) + }) + } + + // ── Sinh Tags (Nếu có) ───────────────────────────────── + let _render-tags = if show-tags and tags.len() > 0 { + ( + h(1fr) + + box[ + #for t in tags { + box(fill: luma(240), inset: (x: 4pt, y: 3pt), radius: 2pt, text( + size: 0.7em, + fill: luma(80), + weight: "bold", + t, + )) + h(2pt) + }] + ) + } else { none } + + // Stem row: [Câu N.] [stem] [fig?] + let q-label = _q-label(prefix, num, accent, style: q-label-style) + let _stem-w-tags = [#stem #_render-tags] + let stem-row = if fig != none and (fig-pos == "right" or fig-pos == "left") { + if fig-pos == "right" { + grid( + columns: (1fr, fig-width), + column-gutter: 14pt, + align: (left + top, center + top), + [#q-label #_stem-w-tags], fig, + ) + } else { + grid( + columns: (fig-width, 1fr), + column-gutter: 14pt, + align: (center + top, left + top), + fig, [#q-label #_stem-w-tags], + ) + } + } else if fig != none and fig-pos == "center" { + [#q-label #_stem-w-tags] + v(0.4em) + _fig-scaled-center(fig, fig-width) + } else { + [#q-label #_stem-w-tags] + } + + [ + #q-state.markers + #_mcq-meta.update(m => ( + m + + ( + ( + num: num, + id: args.named().at("id", default: none), + tags: tags, + ans: if ai >= 0 { labels.at(ai) } else { "?" }, + ), + ) + )) + #_question-frame( + [ + #_maybe-draft( + [ + #stem-row + #v(option-top-gap) + #pad(left: option-indent)[#opts-r] + #if lines > 0 { draw-lines(lines) } + #if mode == "loigiai" and loigiai != none { + v(0.7em) + _sol(loigiai, accent) + } + ], + draft: draft, + draft-width: draft-width, + draft-lines: draft-lines, + draft-title: draft-title, + accent: accent, + ) + ], + below: 1.05em, + boxed: boxed, + fill: box-fill, + stroke: box-stroke, + inset: box-inset, + radius: box-radius, + ) + ] +} + +// ───────────────────────────────────────────────────────── +// TF — True([...]) = đúng, [...] = sai +// Bảng: Phát biểu | □Đ | □S +// ───────────────────────────────────────────────────────── +#let tf( + stem, + statements, + loigiai: none, + mode: "dethi", + accent: palette.accent, + fig: none, + fig-pos: "right", + fig-width: 30%, + ds-style: "table", + lines: 0, + num: auto, + prefix: "Câu", + q-label-style: "plain", + tags: (), + show-tags: true, + draft: false, + draft-width: 30%, + draft-lines: 5, + draft-title: [Nháp], + boxed: false, + box-fill: white, + box-stroke: 0.6pt + palette.border, + box-inset: (x: 10pt, y: 8pt), + box-radius: 4pt, + ..args, +) = context { + let loigiai = _resolve-loigiai(loigiai, args) + let q-state = _next-question-num(num: num) + let num = q-state.num + let vis-ans = mode != "dethi" + let alpha = ("a", "b", "c", "d", "e", "f") + + // Lưu state TF: mảng Đ/S cho từng phát biểu + let tf-row = statements.map(s => if type(s) == dictionary and s.at("correct", default: false) { "Đ" } else { "S" }) + + // Xây dựng bảng + let rows = statements + .enumerate() + .map(((i, s)) => { + let ok = if type(s) == dictionary { s.at("correct", default: false) } else { false } + let txt = if type(s) == dictionary { s.body } else { s } + let md = if vis-ans and ok { text(fill: palette.correct, weight: "bold")[✓] } else { none } + let ms = if vis-ans and not ok { text(fill: palette.wrong, weight: "bold")[✓] } else { none } + let fd = if vis-ans and ok { palette.correct.lighten(80%) } else { white } + let fs = if vis-ans and not ok { palette.wrong.lighten(85%) } else { white } + ( + pad(x: 6pt, y: 4pt)[#text(weight: "bold")[#alpha.at(i))] #h(3pt) #txt], + align(center + horizon)[ + #box(width: 1.8em, height: 1.8em, stroke: 0.6pt + palette.border, fill: fd, align(center + horizon)[#md]) + ], + align(center + horizon)[ + #box(width: 1.8em, height: 1.8em, stroke: 0.6pt + palette.border, fill: fs, align(center + horizon)[#ms]) + ], + ) + }) + .flatten() + + let tbl = table( + columns: (1fr, auto, auto), + stroke: 0.5pt + palette.border, + align: (left + horizon, center + horizon, center + horizon), + table.cell(fill: accent, align: center + horizon, pad(left: 6pt, y: 5pt)[#text( + fill: white, + weight: "bold", + )[Phát biểu]]), + table.cell(fill: accent, align: center + horizon, pad(x: 8pt, y: 5pt)[#text(fill: white, weight: "bold")[Đ]]), + table.cell(fill: accent, align: center + horizon, pad(x: 8pt, y: 5pt)[#text(fill: white, weight: "bold")[S]]), + ..rows, + ) + + // ── Sinh Tags (Nếu có) ───────────────────────────────── + let _render-tags = if show-tags and tags.len() > 0 { + ( + h(1fr) + + box[ + #for t in tags { + box(fill: luma(240), inset: (x: 4pt, y: 3pt), radius: 2pt, text( + size: 0.7em, + fill: luma(80), + weight: "bold", + t, + )) + h(2pt) + }] + ) + } else { none } + + let q-label = _q-label(prefix, num, accent, style: q-label-style) + let _stem-w-tags = [#stem #_render-tags] + let stem-row = if fig != none and (fig-pos == "right" or fig-pos == "left") { + if fig-pos == "right" { + grid( + columns: (1fr, fig-width), + column-gutter: 14pt, + align: (left + top, center + top), + [#q-label #_stem-w-tags], fig, + ) + } else { + grid( + columns: (fig-width, 1fr), + column-gutter: 14pt, + align: (center + top, left + top), + fig, [#q-label #_stem-w-tags], + ) + } + } else if fig != none and fig-pos == "center" { + [#q-label #_stem-w-tags] + v(0.4em) + _fig-scaled-center(fig, fig-width) + } else { + [#q-label #_stem-w-tags] + } + + [ + #q-state.markers + #_tf-meta.update(m => m + ((num: num, id: args.named().at("id", default: none), tags: tags, ans: tf-row),)) + #_question-frame( + [ + #_maybe-draft( + [ + #stem-row + #v(0.6em) + #pad(left: 1.5em)[#tbl] + #if lines > 0 { draw-lines(lines) } + #if mode == "loigiai" and loigiai != none { + v(0.7em) + _sol(loigiai, accent) + } + ], + draft: draft, + draft-width: draft-width, + draft-lines: draft-lines, + draft-title: draft-title, + accent: accent, + ) + ], + below: 1.4em, + boxed: boxed, + fill: box-fill, + stroke: box-stroke, + inset: box-inset, + radius: box-radius, + ) + ] +} +#let hoac(..args) = math.cases(delim: "[", ..args.named(), ..args.pos().map(math.display)) +#let heva(..args) = math.cases(delim: "{", ..args.named(), ..args.pos().map(math.display)) +// tfrac: phân số cỡ inline — dùng trong math mode: $tfrac(1, 2)$ +#let tfrac(a, b) = { + show math.frac: f => f + math.frac(a, b) +} +// ── Hệ thống STEP — tô màu từng bước lời giải ───────────────── +// Dùng: #step[Bước...] #step[Bước...] #reset-step() +#let _step-colors = ( + rgb("#1565c0"), + rgb("#e65100"), + rgb("#2e7d32"), + rgb("#6a1b9a"), + rgb("#00838f"), + rgb("#c62828"), +) +#let _step-cnt = counter("se-step") +#let _step-reveal-config = state("se-step-reveal-config", (before_nonfirst: none)) +#let configure-step-reveal(before_nonfirst: none) = [ + #_step-reveal-config.update((before_nonfirst: before_nonfirst)) +] +#let step(body, color: auto, before_nonfirst: auto) = { + _step-cnt.step() + context { + let n = _step-cnt.get().first() + let c = _step-colors.at(calc.rem(n - 1, _step-colors.len())) + let text-color = if color != auto { color } else { c.darken(10%) } + let border-color = if color != auto { color } else { c } + let reveal = if before_nonfirst != auto { + before_nonfirst + } else if n > 1 { + _step-reveal-config.get().at("before_nonfirst", default: none) + } else { + none + } + let step-block = block( + width: 100%, + stroke: (left: 1.5pt + border-color), + inset: (left: 8pt, right: 0pt, top: 1.5pt, bottom: 1.5pt), + below: 0.3em, + above: 0.3em, + )[ + #text(fill: text-color, weight: "bold")[Bước #n.] #h(0.3em) #body + ] + if reveal == none { + step-block + } else { + [#reveal #step-block] + } + } +} +#let reset-step() = [#_step-cnt.update(0)] +// ───────────────────────────────────────────────────────── +// SHORT — 4 ô trống / hiện đáp án +// ───────────────────────────────────────────────────────── +#let short( + stem, + answer, + loigiai: none, + mode: "dethi", + accent: palette.accent, + fig: none, + fig-pos: "right", + fig-width: 30%, + show-boxes: true, + lines: 0, + num: auto, + prefix: "Câu", + q-label-style: "plain", + tags: (), + show-tags: true, + draft: false, + draft-width: 30%, + draft-lines: 5, + draft-title: [Nháp], + box-count: 4, + boxed: false, + box-fill: white, + box-stroke: 0.6pt + palette.border, + box-inset: (x: 10pt, y: 8pt), + box-radius: 4pt, + ..args, +) = context { + let loigiai = _resolve-loigiai(loigiai, args) + let q-state = _next-question-num(num: num) + let num = q-state.num + + let widget = if mode == "dethi" { + stack(dir: ltr, spacing: 3pt, ..range(box-count).map(_ => box(width: 1.9em, height: 1.9em, stroke: 0.7pt + black))) + } else { + box(fill: palette.correct.lighten(85%), stroke: 1pt + palette.correct, inset: (x: 10pt, y: 3pt), radius: 3pt)[ + #text(weight: "bold", fill: palette.correct, size: 1.05em)[#answer] + ] + } + + // ── Sinh Tags (Nếu có) ───────────────────────────────── + let _render-tags = if show-tags and tags.len() > 0 { + ( + h(1fr) + + box[ + #for t in tags { + box(fill: luma(240), inset: (x: 4pt, y: 3pt), radius: 2pt, text( + size: 0.7em, + fill: luma(80), + weight: "bold", + t, + )) + h(2pt) + }] + ) + } else { none } + + let q-label = _q-label(prefix, num, accent, style: q-label-style) + let _stem-w-tags = [#stem #_render-tags] + let stem-row = if fig != none and (fig-pos == "right" or fig-pos == "left") { + if fig-pos == "right" { + grid( + columns: (1fr, fig-width), + column-gutter: 14pt, + align: (left + top, center + top), + [#q-label #_stem-w-tags], fig, + ) + } else { + grid( + columns: (fig-width, 1fr), + column-gutter: 14pt, + align: (center + top, left + top), + fig, [#q-label #_stem-w-tags], + ) + } + } else if fig != none and fig-pos == "center" { + [#q-label #_stem-w-tags] + v(0.4em) + _fig-scaled-center(fig, fig-width) + } else { + [#q-label #_stem-w-tags] + } + + [ + #q-state.markers + #_sh-meta.update(m => m + ((num: num, id: args.named().at("id", default: none), tags: tags, ans: answer),)) + #_question-frame( + [ + #_maybe-draft( + [ + #stem-row + #if show-boxes { + v(0.6em) + pad(left: 1.5em)[ + #stack(dir: ltr, spacing: 6pt, text(weight: "bold")[Đáp số:], widget) + ] + } + #if lines > 0 { draw-lines(lines) } + #if mode == "loigiai" and loigiai != none { + v(0.7em) + _sol(loigiai, accent) + } + ], + draft: draft, + draft-width: draft-width, + draft-lines: draft-lines, + draft-title: draft-title, + accent: accent, + ) + ], + below: 1.4em, + boxed: boxed, + fill: box-fill, + stroke: box-stroke, + inset: box-inset, + radius: box-radius, + ) + ] +} + +#let tl( + stem, + loigiai: none, + mode: "dethi", + accent: palette.accent, + fig: none, + fig-pos: "right", + fig-width: 30%, + lines: 6, + num: auto, + prefix: "Câu", + q-label-style: "plain", + draft: false, + draft-width: 30%, + draft-lines: 6, + draft-title: [Nháp], + boxed: false, + box-fill: white, + box-stroke: 0.6pt + palette.border, + box-inset: (x: 10pt, y: 8pt), + box-radius: 4pt, + ..args, +) = context { + let loigiai = _resolve-loigiai(loigiai, args) + let q-state = _next-question-num(num: num) + let num = q-state.num + + let q-label = _q-label(prefix, num, accent, style: q-label-style) + let stem-row = if fig != none and (fig-pos == "right" or fig-pos == "left") { + if fig-pos == "right" { + grid( + columns: (1fr, fig-width), + column-gutter: 14pt, + align: (left + top, center + top), + [#q-label #stem], fig, + ) + } else { + grid( + columns: (fig-width, 1fr), + column-gutter: 14pt, + align: (center + top, left + top), + fig, [#q-label #stem], + ) + } + } else if fig != none and fig-pos == "center" { + [#q-label #stem] + v(0.4em) + _fig-scaled-center(fig, fig-width) + } else { + [#q-label #stem] + } + + [ + #q-state.markers + #_tl-meta.update(m => m + ((num: num, id: args.named().at("id", default: none)),)) + #_question-frame( + [ + #_maybe-draft( + [ + #stem-row + #if lines > 0 { draw-lines(lines) } + #if mode == "loigiai" and loigiai != none { + v(0.7em) + _sol(loigiai, accent) + } + ], + draft: draft, + draft-width: draft-width, + draft-lines: draft-lines, + draft-title: draft-title, + accent: accent, + ) + ], + below: 1.4em, + boxed: boxed, + fill: box-fill, + stroke: box-stroke, + inset: box-inset, + radius: box-radius, + ) + ] +} + +#let tn = mcq +#let ds = tf +#let tln = short + +// ── exam-mode ───────────────────────────────────────────── +#let exam-mode( + mode: "dethi", + accent: palette.accent, + opt-style: "plain", + opt-label-color: auto, + q-label-style: "plain", + show-tags: true, + draft: false, + draft-width: 30%, + draft-lines: 5, + boxed: false, + box-fill: white, + box-stroke: 0.6pt + palette.border, +) = ( + tn: tn.with(mode: mode, accent: accent, opt-style: opt-style, opt-label-color: opt-label-color, q-label-style: q-label-style, show-tags: show-tags, draft: draft, draft-width: draft-width, draft-lines: draft-lines, boxed: boxed, box-fill: box-fill, box-stroke: box-stroke), + ds: ds.with(mode: mode, accent: accent, q-label-style: q-label-style, show-tags: show-tags, draft: draft, draft-width: draft-width, draft-lines: draft-lines, boxed: boxed, box-fill: box-fill, box-stroke: box-stroke), + tln: tln.with(mode: mode, accent: accent, q-label-style: q-label-style, show-tags: show-tags, draft: draft, draft-width: draft-width, draft-lines: draft-lines, boxed: boxed, box-fill: box-fill, box-stroke: box-stroke), + tl: tl.with(mode: mode, accent: accent, q-label-style: q-label-style, draft: draft, draft-width: draft-width, draft-lines: draft-lines, boxed: boxed, box-fill: box-fill, box-stroke: box-stroke), + mcq: mcq.with(mode: mode, accent: accent, opt-style: opt-style, opt-label-color: opt-label-color, q-label-style: q-label-style, show-tags: show-tags, draft: draft, draft-width: draft-width, draft-lines: draft-lines, boxed: boxed, box-fill: box-fill, box-stroke: box-stroke), + tf: tf.with(mode: mode, accent: accent, q-label-style: q-label-style, show-tags: show-tags, draft: draft, draft-width: draft-width, draft-lines: draft-lines, boxed: boxed, box-fill: box-fill, box-stroke: box-stroke), + short: short.with(mode: mode, accent: accent, q-label-style: q-label-style, show-tags: show-tags, draft: draft, draft-width: draft-width, draft-lines: draft-lines, boxed: boxed, box-fill: box-fill, box-stroke: box-stroke), +) + +// ── het (HẾT ĐỀ) ─────────────────────────────────────────── +#let het = align(center)[ + #v(2.5em) + #text(weight: "bold")[----------- HẾT -----------] + #v(0.4em) + #text(style: "italic", size: 11pt)[ + Thí sinh không được sử dụng tài liệu. \ + Giám thị không giải thích gì thêm. + ] + #v(1.5em) +] + +// ── exam-part ───────────────────────────────────────────── +// Mặc định `reset-counter: false` để số câu chạy liên tục toàn đề. +// Nếu muốn một phần bắt đầu lại từ Câu 1, dùng `reset-counter: true`. +#let exam-part(title, count: auto, reset-counter: false) = { + // In beamer mode: skip print-only section headers + if sys.inputs.at("beamer", default: "0") == "1" { return } + [ + #if reset-counter { + [#metadata((kind: "question", current: 0)) ] + } + #_part-cnt.step() + #metadata("part") + #v(0.9em) + #block( + width: 100%, + fill: rgb("#e8f0fc"), + stroke: (left: 4pt + palette.accent), + inset: (left: 10pt, right: 8pt, top: 7pt, bottom: 7pt), + radius: (right: 4pt), + )[ + #text(weight: "bold")[#title] + #context { + let c = count + if c == auto { + let markers = query() + let ends = query() + let p-idx = query(selector().before(here())).len() + let current-marker = markers.at(p-idx - 1) + + if p-idx < markers.len() { + c = query(selector(_q-marker).after(current-marker.location()).before(markers.at(p-idx).location())).len() + } else if ends.len() > 0 { + c = query(selector(_q-marker).after(current-marker.location()).before(ends.last().location())).len() + } else { + c = query(selector(_q-marker).after(current-marker.location())).len() + } + } + let title-str = repr(title) + let has-cau = title-str.contains("câu") or title-str.contains("câ u") + if c > 0 and not has-cau { + h(1fr) + text(style: "italic", fill: palette.muted)[(#c câu)] + } + } + ] + #v(0.5em) + ] +} + +// ───────────────────────────────────────────────────────── +// HEADER — Tiêu đề đề thi (v7 — nhiều tuỳ chọn) +// ───────────────────────────────────────────────────────── +#let thpt-school-exam( + body, + department: "BỘ GIÁO DỤC VÀ ĐÀO TẠO", + school: "SỞ GIÁO DỤC VÀ ĐÀO TẠO", + exam-title: "THI THỬ THPT QUỐC GIA 2026", + subject: "TOÁN - LỚP 12", + duration: "90 phút", + structure: auto, // auto = tự đếm từ exam-part + code: "101", + footer-left: none, + accent: palette.accent, + // ── Tuỳ chọn nâng cao ── + show-topbar: false, // thanh màu accent trên đỉnh trang + header-border: true, // đường kẻ dưới tiêu đề + header-font: "Times New Roman", + body-font: "Times New Roman", + body-size: 12pt, + // ── Font viết tay (tuỳ chọn) ── + // Truyền tên font để dùng #hw[...] trong nội dung + // Ví dụ: handwriting-font: "HP001 4 hàng" + // handwriting-font: "Bradley Hand" + handwriting-font: none, + handwriting-size: 1em, // cỡ chữ tương đối so với body-size + q-style: "bold", // "bold" | "boxed" | "pill" + q-color: auto, // auto = accent + watermark: none, // Chữ in chìm phía sau bộ đề + watermark-opacity: 0.1, + watermark-angle: -30deg, + beamer: sys.inputs.at("beamer", default: "0") == "1", // auto-detect via --input beamer=1 +) = { + // In beamer mode: skip all page/header setup, just return body + if beamer { return body } + + let _q-color = if q-color == auto { accent } else { q-color } + + set page( + paper: "a4", + margin: (top: 2cm, bottom: 2cm, x: 1.5cm), + background: if watermark != none { + rotate(watermark-angle, text( + size: 60pt, + fill: luma(100).transparentize(100% - watermark-opacity * 100%), + weight: "bold", + )[#watermark]) + } else { none }, + footer: context { + set text(size: 9pt, fill: palette.muted) + line(length: 100%, stroke: 0.4pt + palette.border) + v(1pt) + let locs = query() + let total = if locs.len() > 0 { locs.last().location().page() } else { counter(page).final().first() } + grid( + columns: (1fr, auto), + align: (left, right), + if footer-left != none { footer-left } else { [#exam-title — #subject] }, + [Trang #counter(page).display() / #total #if code != "" [— Mã Đề: #code]], + ) + }, + ) + set text(font: body-font, size: body-size, lang: "vi") + set par(justify: true, leading: 0.75em) + show math.equation.where(block: false): math.display + + // ── Kích hoạt font viết tay toàn cục ───────────────────── + // Hàm #hw[...] đã được export ở đầu sang-exam.typ + // Chỉ cần update state để hw() đọc đúng font + _hw-font.update(handwriting-font) + _hw-size.update(handwriting-size) + + // Thanh màu accent trên đỉnh (tuỳ chọn) + if show-topbar { + place(top + left, dx: -1.5cm, dy: -2cm, rect(width: 21cm, height: 5pt, fill: accent)) + } + + // Tự đếm structure nếu = auto + let struct-content = if structure != auto { structure } else { + context { + let mc = _mcq-meta.final().len() + let tf = _tf-meta.final().len() + let sh = _sh-meta.final().len() + let essay = _tl-meta.final().len() + let parts = () + if mc > 0 { parts.push([#mc câu trắc nghiệm]) } + if tf > 0 { parts.push([#tf đúng/sai]) } + if sh > 0 { parts.push([#sh trả lời ngắn]) } + if essay > 0 { parts.push([#essay câu tự luận]) } + parts.join([, ]) + } + } + + // Header 2 cột + v(0.3em) + grid( + columns: (1fr, 1fr), + column-gutter: 8pt, + align(center)[ + #set text(font: header-font) + #text(weight: "bold", size: 12pt)[#department] \ + #text(weight: "bold", size: 12pt, fill: accent)[#school] \ + #v(-3pt) #line(length: 35%, stroke: 1pt + accent) #v(1pt) + #text(style: "italic", size: 10pt)[ + (Đề thi có #context { + let locs = query() + if locs.len() > 0 { locs.last().location().page() } else { counter(page).final().first() } + } trang) + ] + ], + align(center)[ + #set text(font: header-font) + #text(weight: "bold", size: 13.5pt, fill: accent)[#upper(exam-title)] \ + #text(weight: "bold", size: 12pt)[MÔN #upper(subject)] \ + #text(style: "italic", size: 10.5pt)[Thời gian làm bài: #duration] \ + #text(style: "italic", size: 10pt)[(Đề có #struct-content)] + ], + ) + + v(1.4em) + grid( + columns: (1fr, auto), + column-gutter: 14pt, + align: (left + bottom, center + bottom), + { + set text(size: 11.5pt) + grid( + columns: (auto, 1fr), + row-gutter: 9pt, + column-gutter: 3pt, + align: (left + bottom, left + bottom), + text(weight: "bold")[Họ và tên thí sinh:], + line(length: 100%, stroke: (paint: black, thickness: 0.6pt, dash: "dotted")), + + text(weight: "bold")[Số Báo Danh:], + line(length: 100%, stroke: (paint: black, thickness: 0.6pt, dash: "dotted")), + ) + }, + if code != "" { + box(stroke: 0.8pt + black, inset: (x: 12pt, y: 7pt))[ + #text(weight: "bold", size: 12pt)[Mã đề thi #code] + ] + }, + ) + v(0.9em) + if header-border { line(length: 100%, stroke: 0.8pt + black) } + v(1em) + body +} + + +// ───────────────────────────────────────────────────────── +// PRINT-ANSWER-KEY — 3 bảng đáp án: MCQ / TF / Short +// ───────────────────────────────────────────────────────── +#let _chunk(arr, n) = { + let out = () + let cur = () + for x in arr { + cur.push(x) + if cur.len() == n { + out.push(cur) + cur = () + } + } + if cur.len() > 0 { out.push(cur) } + out +} + +#let print-answer-key() = context { + let mcq-ans = _mcq-meta.get() + let tf-ans = _tf-meta.get() + let sh-ans = _sh-meta.get() + + v(1.5em) + + // ── Bảng 1: Trắc nghiệm ────────────────────────────── + if mcq-ans.len() > 0 { + v(0.8em) + align(center)[ + #text(weight: "bold", size: 12pt, fill: palette.accent)[BẢNG ĐÁP ÁN — TRẮC NGHIỆM] + ] + v(0.3em) + align(center, table( + columns: (1fr,) * 5, + stroke: 0.5pt + rgb("#cbd5e1"), + align: center + horizon, + inset: (x: 8pt, y: 6pt), + ..mcq-ans.map(it => [ + #text(weight: "bold", fill: rgb("#475569"))[#it.num.] + #h(0.3em) + #text(weight: "bold", fill: palette.accent)[#it.ans] + ]) + )) + v(1em) + } + + // ── Bảng 2: Đúng/Sai ───────────────────────────────── + if tf-ans.len() > 0 { + v(0.8em) + align(center)[ + #text(weight: "bold", size: 12pt, fill: palette.accent)[BẢNG ĐÁP ÁN — ĐÚNG/SAI] + ] + v(0.3em) + align(center, table( + columns: (1.5fr, 1fr, 1fr, 1fr, 1fr), + stroke: 0.5pt + rgb("#cbd5e1"), + align: center + horizon, + inset: (x: 8pt, y: 6pt), + table.cell(fill: rgb("#f1f5f9"), pad(y: 4pt)[#text(weight: "bold", fill: rgb("#334155"))[Câu]]), + ..( + ("a", "b", "c", "d").map(l => { + table.cell(fill: rgb("#f1f5f9"), pad(y: 4pt)[#text(weight: "bold", fill: rgb("#334155"))[#l)]]) + }) + ), + ..tf-ans + .map(item => { + let cells = ( + table.cell(fill: rgb("#f8fafc"), pad(y: 4pt)[#text(weight: "bold", fill: rgb("#475569"))[Câu #item.num]]), + ) + for vv in item.ans { + let col = if vv == "Đ" { palette.correct } else { palette.wrong } + cells = cells + (pad(y: 4pt)[#text(weight: "bold", fill: col)[#vv]],) + } + cells + }) + .flatten(), + )) + v(1em) + } + + // ── Bảng 3: Điền số ─────────────────────────────────── + if sh-ans.len() > 0 { + v(0.8em) + align(center)[ + #text(weight: "bold", size: 12pt, fill: palette.accent)[BẢNG ĐÁP ÁN — ĐIỀN SỐ] + ] + v(0.3em) + align(center, table( + columns: (1fr,) * 5, + stroke: 0.5pt + rgb("#cbd5e1"), + align: center + horizon, + inset: (x: 8pt, y: 6pt), + ..sh-ans.map(it => [ + #text(weight: "bold", fill: rgb("#475569"))[#it.num.] + #h(0.3em) + #text(weight: "bold", fill: palette.accent)[#it.ans] + ]) + )) + } +} + +// ───────────────────────────────────────────────────────── +// CÔNG CỤ TOÁN HỌC (MATH TOOLS) +// ───────────────────────────────────────────────────────── +// vect(AB) → dấu mũi tên chuẩn LaTeX \overrightarrow +#let vect(..args) = { + let body = args.pos().map(a => if type(a) == str { math.upright(a) } else { a }).join() + math.accent(body, math.arrow.r) +} diff --git a/packages/preview/sang-math/1.0.0/typst.toml b/packages/preview/sang-math/1.0.0/typst.toml new file mode 100644 index 0000000000..9f5e46f412 --- /dev/null +++ b/packages/preview/sang-math/1.0.0/typst.toml @@ -0,0 +1,10 @@ +[package] +name = "sang-math" +version = "1.0.0" +entrypoint = "lib.typ" +compiler = "0.11.0" +authors = ["Sang Nguyen "] +license = "MIT" +description = "A comprehensive toolkit for typesetting high school mathematics (Vietnamese curriculum) – variations, sign tables, multiple choice, exams, and CeTZ geometry." +keywords = ["math", "exam", "vietnam", "thpt", "bbt"] +