@chengzhao-dev/dsh-quarto-book 0.4.3 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +2 -2
- package/README.md +2 -2
- package/package.json +5 -2
- package/skills/designing-theme/SKILL.md +2 -2
- package/skills/designing-theme/assets/theme/css/base.css +216 -55
- package/skills/designing-theme/assets/theme/css/callouts.css +32 -23
- package/skills/designing-theme/assets/theme/css/code.css +84 -2
- package/skills/designing-theme/assets/theme/css/content.css +152 -36
- package/skills/designing-theme/assets/theme/css/landing.css +150 -52
- package/skills/designing-theme/assets/theme/css/nav.css +462 -35
- package/skills/designing-theme/assets/theme/css/sidebar.css +76 -31
- package/skills/designing-theme/assets/theme/css/tokens.css +34 -16
- package/skills/designing-theme/assets/theme/includes/fonts.html +1 -1
- package/skills/designing-theme/assets/theme/includes/footer.html +16 -7
- package/skills/designing-theme/assets/theme/includes/palette.html +420 -0
- package/skills/designing-theme/assets/theme/palettes/flexoki/brand-mark.svg +3 -0
- package/skills/designing-theme/assets/theme/palettes/flexoki/favicon-dark.svg +5 -0
- package/skills/designing-theme/assets/theme/palettes/flexoki/favicon-light.svg +5 -0
- package/skills/designing-theme/assets/theme/palettes/flexoki/highlight-dark.theme +214 -0
- package/skills/designing-theme/assets/theme/palettes/flexoki/highlight-light.theme +214 -0
- package/skills/designing-theme/assets/theme/palettes/flexoki/meta.md +53 -0
- package/skills/designing-theme/assets/theme/palettes/flexoki/theme-dark.scss +43 -0
- package/skills/designing-theme/assets/theme/palettes/flexoki/theme-light.scss +40 -0
- package/skills/designing-theme/assets/theme/palettes/flexoki/tokens.css +156 -0
- package/skills/designing-theme/assets/theme/palettes/hill-light/brand-mark.svg +3 -0
- package/skills/designing-theme/assets/theme/palettes/hill-light/favicon-dark.svg +5 -0
- package/skills/designing-theme/assets/theme/palettes/hill-light/favicon-light.svg +5 -0
- package/skills/designing-theme/assets/theme/palettes/hill-light/meta.md +78 -0
- package/skills/designing-theme/assets/theme/palettes/hill-light/syntax.css +150 -0
- package/skills/designing-theme/assets/theme/palettes/hill-light/tokens.css +322 -0
- package/skills/designing-theme/assets/theme/palettes/solarized/brand-mark.svg +3 -0
- package/skills/designing-theme/assets/theme/palettes/solarized/favicon-dark.svg +5 -0
- package/skills/designing-theme/assets/theme/palettes/solarized/favicon-light.svg +5 -0
- package/skills/designing-theme/assets/theme/palettes/solarized/highlight-dark.theme +214 -0
- package/skills/designing-theme/assets/theme/palettes/solarized/highlight-light.theme +214 -0
- package/skills/designing-theme/assets/theme/palettes/solarized/meta.md +66 -0
- package/skills/designing-theme/assets/theme/palettes/solarized/syntax.css +67 -0
- package/skills/designing-theme/assets/theme/palettes/solarized/theme-dark.scss +43 -0
- package/skills/designing-theme/assets/theme/palettes/solarized/theme-light.scss +41 -0
- package/skills/designing-theme/assets/theme/palettes/solarized/tokens.css +154 -0
- package/skills/designing-theme/knowledge/reading-surface.md +27 -15
- package/skills/designing-theme/references/theme-spine.md +2 -2
- package/skills/designing-theme/references/theme-system.md +82 -36
- package/skills/designing-theme/scripts/checking-layout.py +306 -48
- package/skills/designing-theme/scripts/measuring-pages.mjs +40 -3
- package/skills/writing-quarto/quarto/render.md +2 -2
- package/skills/writing-quarto/scripts/checking-dom.py +14 -7
- package/skills/designing-theme/assets/theme/assets/brand-mark.svg +0 -4
- package/skills/designing-theme/assets/theme/assets/favicon.svg +0 -9
- package/skills/designing-theme/assets/theme/palettes/github/highlight-dark.theme +0 -213
- package/skills/designing-theme/assets/theme/palettes/github/highlight-light.theme +0 -213
- package/skills/designing-theme/assets/theme/palettes/github/meta.md +0 -38
- package/skills/designing-theme/assets/theme/palettes/github/theme-dark.scss +0 -35
- package/skills/designing-theme/assets/theme/palettes/github/theme-light.scss +0 -34
- package/skills/designing-theme/assets/theme/palettes/github/tokens.css +0 -160
|
@@ -63,14 +63,14 @@ def active_palette_name(quarto_text=""):
|
|
|
63
63
|
SHARED_CHECKS = [
|
|
64
64
|
("body line-height 1.75", "line-height: 1.75"),
|
|
65
65
|
("content width", "--content-width: 840px"),
|
|
66
|
-
("code font
|
|
67
|
-
("code line-height 1.
|
|
66
|
+
("code font 15px", "--code-font-size: 0.9375rem"),
|
|
67
|
+
("code line-height 1.7", "--code-line-height: 1.7"),
|
|
68
68
|
("code title padding token", "--code-title-padding"),
|
|
69
69
|
("ui font stack", '--ui-font: "LXGW Bright Medium"'),
|
|
70
70
|
("cjk font fallback", '"LXGW Bright Medium"'),
|
|
71
71
|
("code font stack", '--mono-font: "LXGW Bright Code"'),
|
|
72
72
|
("bright code family", '"LXGW Bright Code"'),
|
|
73
|
-
("code followup gap", "--code-followup-gap:
|
|
73
|
+
("code followup gap", "--code-followup-gap: 0.9rem"),
|
|
74
74
|
("list code followup spacing", "margin-top: var(--code-followup-gap)"),
|
|
75
75
|
("list code with filename selector", "li > :is(.code-copy-outer-scaffold, .code-with-filename"),
|
|
76
76
|
("readable body font", "font-size: 1rem"),
|
|
@@ -79,7 +79,8 @@ SHARED_CHECKS = [
|
|
|
79
79
|
("answer disclosure container", "details.answer-disclosure"),
|
|
80
80
|
("list marker token color", "li::marker"),
|
|
81
81
|
("answer disclosure summary", "details.answer-disclosure > summary"),
|
|
82
|
-
("feature-grid max two columns", "
|
|
82
|
+
("feature-grid max two columns", "repeat(2, minmax(0, 1fr))"),
|
|
83
|
+
("feature-grid single card full row", ":has(> .landing-card:only-child)"),
|
|
83
84
|
]
|
|
84
85
|
|
|
85
86
|
# 语义令牌名必须存在(色值由活跃 palette 提供)
|
|
@@ -93,42 +94,117 @@ TOKEN_NAME_CHECKS = [
|
|
|
93
94
|
("callout important (deep-dive semantics)", "--callout-important-border"),
|
|
94
95
|
]
|
|
95
96
|
|
|
96
|
-
# palettes/
|
|
97
|
+
# palettes/flexoki 色值期望(编译默认)。换 pack 时改对应表。
|
|
97
98
|
PALETTE_VALUE_CHECKS = {
|
|
98
|
-
"
|
|
99
|
-
("light body token #
|
|
100
|
-
("light
|
|
101
|
-
("light
|
|
102
|
-
("
|
|
103
|
-
("light
|
|
104
|
-
("dark
|
|
105
|
-
("
|
|
106
|
-
("dark
|
|
107
|
-
("dark
|
|
108
|
-
("dark
|
|
109
|
-
("
|
|
110
|
-
("
|
|
111
|
-
("
|
|
112
|
-
("
|
|
113
|
-
("
|
|
99
|
+
"flexoki": [
|
|
100
|
+
("light body token #100F0F", "--body-color: #100F0F"),
|
|
101
|
+
("light emphasis #100F0F", "--emphasis-color: #100F0F"),
|
|
102
|
+
("light line numbers #575653", "--line-num-color: #575653"),
|
|
103
|
+
("light Flexoki link ink #100F0F", "--link-color: #100F0F"),
|
|
104
|
+
("light navbar page-bg token", "--navbar-bg: #FFFCF0"),
|
|
105
|
+
("dark navbar page-bg token", "--navbar-bg: #100F0F"),
|
|
106
|
+
("light code background", "--code-bg: #F2F0E5"),
|
|
107
|
+
("dark code background", "--code-bg: #1C1B1A"),
|
|
108
|
+
("dark page #100F0F", "#100F0F"),
|
|
109
|
+
("dark body #CECDC3", "#CECDC3"),
|
|
110
|
+
("dark secondary #9F9D96", "#9F9D96"),
|
|
111
|
+
("dark link ink #CECDC3", "--link-color: #CECDC3"),
|
|
112
|
+
("callout note border light", "--callout-note-border: #6F6E69"),
|
|
113
|
+
("callout warning border light", "--callout-warning-border: #BC5215"),
|
|
114
|
+
("light highlight comment #575653", "#575653"),
|
|
115
|
+
("light highlight keyword #66800b", "#66800b"),
|
|
116
|
+
("dark highlight comment #878580", "#878580"),
|
|
117
|
+
("dark highlight keyword #879a39", "#879a39"),
|
|
114
118
|
],
|
|
115
119
|
}
|
|
116
120
|
|
|
121
|
+
# Solarized 是运行时覆盖层:令牌必须挂在 html[data-palette="solarized"] 下。
|
|
122
|
+
SOLARIZED_TOKEN_CHECKS = [
|
|
123
|
+
("solarized token selector", 'html[data-palette="solarized"] body'),
|
|
124
|
+
("solarized light page #FDF6E3", "--page-bg: #FDF6E3"),
|
|
125
|
+
("solarized light body #526C74", "--body-color: #526C74"),
|
|
126
|
+
("solarized light link rebase #586E75", "--link-color: #586E75"),
|
|
127
|
+
("solarized light code text #526C74", "--code-fg: #526C74"),
|
|
128
|
+
("solarized light code #EEE8D5", "--code-bg: #EEE8D5"),
|
|
129
|
+
("solarized dark page #002B36", "--page-bg: #002B36"),
|
|
130
|
+
("solarized dark body #839496", "--body-color: #839496"),
|
|
131
|
+
("solarized dark link rebase #93A1A1", "--link-color: #93A1A1"),
|
|
132
|
+
("solarized link hover action", "--link-hover-color: var(--action-color)"),
|
|
133
|
+
("solarized dark code #073642", "--code-bg: #073642"),
|
|
134
|
+
]
|
|
135
|
+
|
|
136
|
+
SOLARIZED_SYNTAX_CHECKS = [
|
|
137
|
+
("solarized syntax selector", 'html[data-palette="solarized"] code span.kw'),
|
|
138
|
+
("solarized keyword #859900", "#859900"),
|
|
139
|
+
("solarized light comment #556C73", "#556C73"),
|
|
140
|
+
("solarized light syntax blue #1B6EA3", "#1B6EA3"),
|
|
141
|
+
("solarized dark comment #93A1A1", "#93A1A1"),
|
|
142
|
+
("solarized dark syntax blue #51A0D5", "#51A0D5"),
|
|
143
|
+
]
|
|
144
|
+
|
|
145
|
+
# 山光是运行时覆盖层,四时辰都挂在 html[data-palette="hill-light"][data-mode] 下。
|
|
146
|
+
HILL_LIGHT_TOKEN_CHECKS = [
|
|
147
|
+
("hill-light dawn selector", 'html[data-palette="hill-light"][data-mode="dawn"] body'),
|
|
148
|
+
("hill-light noon selector", 'html[data-palette="hill-light"][data-mode="noon"] body'),
|
|
149
|
+
("hill-light dusk selector", 'html[data-palette="hill-light"][data-mode="dusk"] body'),
|
|
150
|
+
("hill-light night selector", 'html[data-palette="hill-light"][data-mode="night"] body'),
|
|
151
|
+
("hill-light night native dark controls", "color-scheme: dark;"),
|
|
152
|
+
("hill-light night print fallback", "@media print"),
|
|
153
|
+
]
|
|
154
|
+
|
|
155
|
+
HILL_LIGHT_SYNTAX_CHECKS = [
|
|
156
|
+
("hill-light syntax base rule", '[data-mode="dawn"] code span:not([class])'),
|
|
157
|
+
("hill-light dawn keyword", 'html[data-palette="hill-light"][data-mode="dawn"] code span.kw'),
|
|
158
|
+
("hill-light noon keyword", 'html[data-palette="hill-light"][data-mode="noon"] code span.kw'),
|
|
159
|
+
("hill-light dusk keyword", 'html[data-palette="hill-light"][data-mode="dusk"] code span.kw'),
|
|
160
|
+
("hill-light night keyword", 'html[data-palette="hill-light"][data-mode="night"] code span.kw'),
|
|
161
|
+
("hill-light night comment", 'html[data-palette="hill-light"][data-mode="night"] code span.co'),
|
|
162
|
+
]
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def lu(hex_color: str) -> float:
|
|
166
|
+
"""WCAG 相对亮度。"""
|
|
167
|
+
h = hex_color.lstrip("#")
|
|
168
|
+
r, g, b = (int(h[i:i + 2], 16) / 255 for i in (0, 2, 4))
|
|
169
|
+
lin = [c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4 for c in (r, g, b)]
|
|
170
|
+
return 0.2126 * lin[0] + 0.7152 * lin[1] + 0.0722 * lin[2]
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def check_hill_light_modes(tokens_text: str) -> int:
|
|
174
|
+
"""山光四时辰:前三个时辰浅纸、夜月暗纸;打印时夜月回落浅纸。"""
|
|
175
|
+
fails = 0
|
|
176
|
+
values = re.findall(r"--page-bg: (#[0-9A-Fa-f]{6})", tokens_text)
|
|
177
|
+
if len(values) < 5:
|
|
178
|
+
print(f" MISS hill-light page-bg count (found {len(values)}, want >= 5)")
|
|
179
|
+
return 1
|
|
180
|
+
names = ("dawn", "noon", "dusk")
|
|
181
|
+
for name, v in zip(names, values[:3]):
|
|
182
|
+
ok = lu(v) > 0.55
|
|
183
|
+
print(f" {'OK ' if ok else 'MISS'} hill-light {name} paper is light ({v} L={lu(v):.2f})")
|
|
184
|
+
fails += 0 if ok else 1
|
|
185
|
+
night_ok = lu(values[3]) < 0.15
|
|
186
|
+
print(f" {'OK ' if night_ok else 'MISS'} hill-light night is dark ({values[3]} L={lu(values[3]):.2f})")
|
|
187
|
+
fails += 0 if night_ok else 1
|
|
188
|
+
print_ok = abs(lu(values[4]) - lu(values[0])) < 0.02
|
|
189
|
+
print(f" {'OK ' if print_ok else 'MISS'} hill-light night print falls back to dawn paper ({values[4]})")
|
|
190
|
+
fails += 0 if print_ok else 1
|
|
191
|
+
return fails
|
|
192
|
+
|
|
193
|
+
|
|
117
194
|
# 兼容旧名:测试与外部引用仍可能导入 CHECKS。
|
|
118
|
-
CHECKS = SHARED_CHECKS + TOKEN_NAME_CHECKS + PALETTE_VALUE_CHECKS["
|
|
195
|
+
CHECKS = SHARED_CHECKS + TOKEN_NAME_CHECKS + PALETTE_VALUE_CHECKS["flexoki"]
|
|
119
196
|
|
|
120
197
|
CALLOUT_CHECKS = [
|
|
121
198
|
("callout body font 16px", "font-size: 1rem;"),
|
|
122
|
-
("callout title font
|
|
123
|
-
("callout padding
|
|
124
|
-
("callout header gap
|
|
199
|
+
("callout title font 13px", "font-size: 0.8125rem;"),
|
|
200
|
+
("callout padding", "padding: 0.1rem 0 0.1rem 1.1rem;"),
|
|
201
|
+
("callout header gap", "margin: 0 0 0.35rem;"),
|
|
125
202
|
]
|
|
126
203
|
|
|
127
204
|
FORBIDDEN_FONT_REFERENCES = [
|
|
128
205
|
("Fixel reference", "Fixel"),
|
|
129
206
|
("LXGW WenKai Screen reference", "LXGW WenKai Screen"),
|
|
130
207
|
("Inter reference", "Inter"),
|
|
131
|
-
("Noto Sans SC reference", "Noto Sans SC"),
|
|
132
208
|
("Noto Sans Mono CJK SC reference", "Noto Sans Mono CJK SC"),
|
|
133
209
|
("JetBrains Mono reference", "JetBrains Mono"),
|
|
134
210
|
("local font fallback", "local("),
|
|
@@ -317,6 +393,9 @@ def browser_layout(book_dir, verbose):
|
|
|
317
393
|
f" (node={node or '无'}, NODE_PATH={playwright_root or '无'})"
|
|
318
394
|
), {}
|
|
319
395
|
|
|
396
|
+
# 测量目标是渲染产物:书目录里还可能有主题模板(theme/includes/*.html),
|
|
397
|
+
# 它们没有 #quarto-document-content,混进采样会误报。优先用 _book。
|
|
398
|
+
render_dir = book_dir / "_book" if (book_dir / "_book").is_dir() else book_dir
|
|
320
399
|
shots_dir = ROOT / "temp" / "theme-regression"
|
|
321
400
|
out_file = shots_dir / "measure.json"
|
|
322
401
|
shots_dir.mkdir(parents=True, exist_ok=True)
|
|
@@ -325,7 +404,7 @@ def browser_layout(book_dir, verbose):
|
|
|
325
404
|
node,
|
|
326
405
|
str(MEASURE_SCRIPT),
|
|
327
406
|
"--book-dir",
|
|
328
|
-
str(
|
|
407
|
+
str(render_dir),
|
|
329
408
|
"--out",
|
|
330
409
|
str(out_file),
|
|
331
410
|
"--shots-dir",
|
|
@@ -361,7 +440,7 @@ def browser_layout(book_dir, verbose):
|
|
|
361
440
|
for _family, url in font_face_entries(FONTS_CSS.read_text(encoding="utf-8")):
|
|
362
441
|
target = (FONTS_CSS.parent / url).resolve()
|
|
363
442
|
font_assets_by_name[target.name] = target.stat().st_size if target.is_file() else 0
|
|
364
|
-
width_floor = {1280: 640, 768: 500, 390: 320}
|
|
443
|
+
width_floor = {1280: 640, 768: 500, 390: 320, 360: 300}
|
|
365
444
|
for width_text, pages in metrics["viewports"].items():
|
|
366
445
|
width = int(width_text)
|
|
367
446
|
for key, data in pages.items():
|
|
@@ -371,6 +450,9 @@ def browser_layout(book_dir, verbose):
|
|
|
371
450
|
problems.append(f"{label}: {data['error']}")
|
|
372
451
|
continue
|
|
373
452
|
requested_fonts = data.get("requestedFonts", [])
|
|
453
|
+
# MathJax 自带 CHTML 字体(mjx-*):数学公式必须用它才有 TeX 字形,
|
|
454
|
+
# 不算「主题外字体漂移」,也不计入本站字体预算。
|
|
455
|
+
requested_fonts = [name for name in requested_fonts if not name.startswith("mjx-")]
|
|
374
456
|
unknown_fonts = sorted(set(requested_fonts) - set(font_assets_by_name))
|
|
375
457
|
if unknown_fonts:
|
|
376
458
|
problems.append(f"{label}: unknown font requests {unknown_fonts}")
|
|
@@ -411,6 +493,33 @@ def browser_layout(book_dir, verbose):
|
|
|
411
493
|
problems.append(
|
|
412
494
|
f"{label}: 窄屏左栏浮层底色 alpha={alpha},正文会透上来,需不透明"
|
|
413
495
|
)
|
|
496
|
+
# 栏间分隔只留顶栏下那一条通栏横线:桌面左栏不得有右边框,
|
|
497
|
+
# 顶栏底边恰好 1px 且次级导航不再自己画一条,正文 h2 不带伪元素短线。
|
|
498
|
+
# 依据见 references/theme-system.md「定尺度」与「组件约定·章节线」。
|
|
499
|
+
if overlay and width > 991.98 and overlay.get("borderRightWidth", 0) != 0:
|
|
500
|
+
problems.append(
|
|
501
|
+
f"{label}: 桌面左栏 border-right="
|
|
502
|
+
f"{overlay['borderRightWidth']}px,应为 0(栏间不画竖线)"
|
|
503
|
+
)
|
|
504
|
+
header_rule = data.get("headerRule") or {}
|
|
505
|
+
if header_rule:
|
|
506
|
+
if abs(header_rule.get("borderBottomWidth", 0) - 1) > 0.01:
|
|
507
|
+
problems.append(
|
|
508
|
+
f"{label}: 顶栏底边 "
|
|
509
|
+
f"{header_rule.get('borderBottomWidth')}px,应为 1px 通栏发丝线"
|
|
510
|
+
)
|
|
511
|
+
if header_rule.get("secondaryBorderBottomWidth", 0):
|
|
512
|
+
problems.append(
|
|
513
|
+
f"{label}: 次级导航底边 "
|
|
514
|
+
f"{header_rule['secondaryBorderBottomWidth']}px,会和顶栏底边叠成双线"
|
|
515
|
+
)
|
|
516
|
+
h2_after = data.get("h2Rules") or []
|
|
517
|
+
if h2_after:
|
|
518
|
+
first = h2_after[0]
|
|
519
|
+
problems.append(
|
|
520
|
+
f"{label}: h2「{first['text']}」仍有装饰短线 "
|
|
521
|
+
f"{first['afterWidth']}×{first['afterHeight']},章节不画线"
|
|
522
|
+
)
|
|
414
523
|
# 侧栏折叠层级:桌面视口下只有当前页所在的分组默认展开。
|
|
415
524
|
# 其余分组折叠。依据见本仓 designing-theme 技能的主题说明。
|
|
416
525
|
sections = data.get("sidebarSections")
|
|
@@ -427,24 +536,25 @@ def browser_layout(book_dir, verbose):
|
|
|
427
536
|
f"{label}: 侧栏展开的不是当前页所在分组 "
|
|
428
537
|
f"(展开={expanded[0]['text']})"
|
|
429
538
|
)
|
|
430
|
-
|
|
539
|
+
# Callout 按 theme-system.md「组件约定」是薄条:2px 左色条、无填充、无圆角,
|
|
540
|
+
# 标题 13px、正文 16px,上下内边距各 0.1rem(根字号 16px 时 1.6px)。
|
|
541
|
+
# 不跟正文 17px 比——Callout 是附注层,比正文低一档是有意为之。
|
|
431
542
|
for index, callout in enumerate(data.get("callouts", []), 1):
|
|
432
|
-
if abs(callout["bodyFontSize"] -
|
|
543
|
+
if abs(callout["bodyFontSize"] - 16) > 0.01:
|
|
433
544
|
problems.append(
|
|
434
|
-
f"{label}: Callout#{index} 正文 {callout['bodyFontSize']}px "
|
|
435
|
-
f"!= 正文 {paragraph_font_size}px"
|
|
545
|
+
f"{label}: Callout#{index} 正文 {callout['bodyFontSize']}px != 16px"
|
|
436
546
|
)
|
|
437
|
-
if abs(callout["titleFontSize"] -
|
|
547
|
+
if abs(callout["titleFontSize"] - 13) > 0.01:
|
|
438
548
|
problems.append(
|
|
439
|
-
f"{label}: Callout#{index} 标题 {callout['titleFontSize']}px !=
|
|
549
|
+
f"{label}: Callout#{index} 标题 {callout['titleFontSize']}px != 13px"
|
|
440
550
|
)
|
|
441
551
|
if (
|
|
442
|
-
abs(callout["paddingTop"] -
|
|
443
|
-
or abs(callout["paddingBottom"] -
|
|
552
|
+
abs(callout["paddingTop"] - 1.6) > 0.05
|
|
553
|
+
or abs(callout["paddingBottom"] - 1.6) > 0.05
|
|
444
554
|
):
|
|
445
555
|
problems.append(
|
|
446
556
|
f"{label}: Callout#{index} 内边距 "
|
|
447
|
-
f"{callout['paddingTop']}/{callout['paddingBottom']}px !=
|
|
557
|
+
f"{callout['paddingTop']}/{callout['paddingBottom']}px != 1.6/1.6px"
|
|
448
558
|
)
|
|
449
559
|
if callout["width"] + 1 < data["paragraphWidth"]:
|
|
450
560
|
problems.append(
|
|
@@ -493,14 +603,31 @@ def browser_layout(book_dir, verbose):
|
|
|
493
603
|
return problems, None, info
|
|
494
604
|
|
|
495
605
|
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
)
|
|
606
|
+
# 自托管字面必须覆盖的码位:汉字与中文标点之外,还包括西文标点、数学/箭头的
|
|
607
|
+
# 「书中符号」。漏了它们不会被发现,却会静默回落到系统字体——同一个「+」会按
|
|
608
|
+
# 系统 CJK 字体的中线画在 0.5em 高度,紧挨 0.23em 的拉丁基线,肉眼就是「++ 偏上」。
|
|
609
|
+
# 表情符号(≥U+1F000 与 U+2600–U+27BF 的装饰符)按设计就该由系统 emoji 字体绘制,不在断言内。
|
|
610
|
+
GLYPH_RANGES = (
|
|
611
|
+
(0x00A0, 0x024F), # Latin-1 补充 + Latin Extended-A
|
|
612
|
+
(0x2000, 0x206F), # 通用标点(引号、破折号、省略号、间隔号)
|
|
613
|
+
(0x20A0, 0x20BF), # 货币符号
|
|
614
|
+
(0x2100, 0x214F), # 字母式符号(℃ ™ №)
|
|
615
|
+
(0x2190, 0x21FF), # 箭头
|
|
616
|
+
(0x2200, 0x22FF), # 数学运算符
|
|
617
|
+
(0x2500, 0x257F), # 制表符(终端输出里的 ├ ─ └ │)
|
|
618
|
+
(0x25A0, 0x25FF), # 几何图形(■ □ ▲)
|
|
619
|
+
(0x2E80, 0x2EFF), # CJK 部首补充
|
|
620
|
+
(0x3000, 0x303F), # CJK 标点
|
|
621
|
+
(0x4E00, 0x9FFF), # 汉字
|
|
622
|
+
(0xF900, 0xFAFF), # CJK 兼容汉字
|
|
623
|
+
(0xFE30, 0xFE4F), # CJK 兼容形式
|
|
624
|
+
(0xFF00, 0xFFEF), # 全角形式(+ - = ())
|
|
625
|
+
)
|
|
626
|
+
|
|
627
|
+
|
|
628
|
+
def needs_glyph_coverage(codepoint):
|
|
629
|
+
"""Assert self-hosted coverage for CJK text, CJK punctuation and book symbols."""
|
|
630
|
+
return any(start <= codepoint <= end for start, end in GLYPH_RANGES)
|
|
504
631
|
|
|
505
632
|
|
|
506
633
|
def read_font_cmap(path):
|
|
@@ -563,13 +690,90 @@ def check_font_coverage(book_dir):
|
|
|
563
690
|
for char in chunk:
|
|
564
691
|
codepoint = ord(char)
|
|
565
692
|
if (
|
|
566
|
-
|
|
693
|
+
needs_glyph_coverage(codepoint)
|
|
567
694
|
and codepoint not in coverage[family]
|
|
568
695
|
):
|
|
569
696
|
missing[family].add(char)
|
|
570
697
|
return coverage, missing
|
|
571
698
|
|
|
572
699
|
|
|
700
|
+
def _parse_hex_color(value):
|
|
701
|
+
match = re.fullmatch(r"#([0-9a-fA-F]{6})", value.strip())
|
|
702
|
+
if not match:
|
|
703
|
+
return None
|
|
704
|
+
digits = match.group(1)
|
|
705
|
+
return tuple(int(digits[i : i + 2], 16) for i in (0, 2, 4))
|
|
706
|
+
|
|
707
|
+
|
|
708
|
+
def _relative_luminance(rgb):
|
|
709
|
+
channels = []
|
|
710
|
+
for value in rgb:
|
|
711
|
+
v = value / 255
|
|
712
|
+
channels.append(v / 12.92 if v <= 0.03928 else ((v + 0.055) / 1.055) ** 2.4)
|
|
713
|
+
return 0.2126 * channels[0] + 0.7152 * channels[1] + 0.0722 * channels[2]
|
|
714
|
+
|
|
715
|
+
|
|
716
|
+
def _contrast_ratio(fg, bg):
|
|
717
|
+
hi, lo = sorted((_relative_luminance(fg), _relative_luminance(bg)), reverse=True)
|
|
718
|
+
return (hi + 0.05) / (lo + 0.05)
|
|
719
|
+
|
|
720
|
+
|
|
721
|
+
def check_palette_contrast():
|
|
722
|
+
"""色板正文/次要/链接/代码前景对各自底色都需 >= 4.5:1。
|
|
723
|
+
|
|
724
|
+
只读 tokens.css 的顶层定义块,跳过 prefers-contrast 的增强段:
|
|
725
|
+
那里本来就该更高。Solarized 的语法彩色 token 由官方配色决定,
|
|
726
|
+
不在本断言范围内(见 theme-system.md 的对比度说明)。
|
|
727
|
+
"""
|
|
728
|
+
|
|
729
|
+
palette_dir = THEME_DIR / "assets" / "theme" / "palettes"
|
|
730
|
+
pairs = (
|
|
731
|
+
("body", "--body-color", "--page-bg"),
|
|
732
|
+
("secondary", "--text-secondary", "--page-bg"),
|
|
733
|
+
("link", "--link-color", "--page-bg"),
|
|
734
|
+
("code", "--code-fg", "--code-bg"),
|
|
735
|
+
("line numbers", "--line-num-color", "--code-bg"),
|
|
736
|
+
("code title", "--code-title-fg", "--code-title-bg"),
|
|
737
|
+
)
|
|
738
|
+
failures = []
|
|
739
|
+
for palette in ("flexoki", "solarized", "hill-light"):
|
|
740
|
+
tokens_path = palette_dir / palette / "tokens.css"
|
|
741
|
+
if not tokens_path.is_file():
|
|
742
|
+
continue
|
|
743
|
+
css = tokens_path.read_text(encoding="utf-8")
|
|
744
|
+
css = re.sub(r"/\*.*?\*/", "", css, flags=re.S)
|
|
745
|
+
for match in re.finditer(r"([^{}]+)\{([^{}]*)\}", css):
|
|
746
|
+
selector = " ".join(match.group(1).split())
|
|
747
|
+
if "@media" in selector or "prefers-contrast" in selector:
|
|
748
|
+
continue
|
|
749
|
+
body = match.group(2)
|
|
750
|
+
if "--page-bg" not in body:
|
|
751
|
+
continue
|
|
752
|
+
mode_match = re.search(r'data-mode="(\w+)"', selector)
|
|
753
|
+
if palette == "hill-light":
|
|
754
|
+
if " body" not in selector:
|
|
755
|
+
continue
|
|
756
|
+
mode = mode_match.group(1) if mode_match else "dawn"
|
|
757
|
+
else:
|
|
758
|
+
if " body" not in selector:
|
|
759
|
+
continue
|
|
760
|
+
mode = "dark" if "quarto-dark" in selector else "light"
|
|
761
|
+
values = dict(re.findall(r"(--[a-z0-9-]+)\s*:\s*([^;]+);", body))
|
|
762
|
+
for name, fg_token, bg_token in pairs:
|
|
763
|
+
fg = _parse_hex_color(values.get(fg_token, ""))
|
|
764
|
+
bg = _parse_hex_color(values.get(bg_token, ""))
|
|
765
|
+
if not fg or not bg:
|
|
766
|
+
continue
|
|
767
|
+
ratio = _contrast_ratio(fg, bg)
|
|
768
|
+
if ratio < 4.5:
|
|
769
|
+
failures.append(
|
|
770
|
+
f"{palette}/{mode}: {name} {ratio:.2f}:1 "
|
|
771
|
+
f"({values.get(fg_token).strip()} on {values.get(bg_token).strip()})"
|
|
772
|
+
)
|
|
773
|
+
return failures
|
|
774
|
+
|
|
775
|
+
|
|
776
|
+
|
|
573
777
|
def main():
|
|
574
778
|
try:
|
|
575
779
|
sys.stdout.reconfigure(encoding="utf-8")
|
|
@@ -638,6 +842,45 @@ def main():
|
|
|
638
842
|
if not found:
|
|
639
843
|
fail += 1
|
|
640
844
|
|
|
845
|
+
# Solarized 不参与编译,但两份覆盖层必须始终可用;按文件分别核 selector 与色值。
|
|
846
|
+
solarized_tokens = PALETTE_ROOT / "solarized" / "tokens.css"
|
|
847
|
+
solarized_syntax = PALETTE_ROOT / "solarized" / "syntax.css"
|
|
848
|
+
solarized_texts = {}
|
|
849
|
+
for path in (solarized_tokens, solarized_syntax):
|
|
850
|
+
if path.is_file():
|
|
851
|
+
solarized_texts[path.name] = path.read_text(
|
|
852
|
+
encoding="utf-8", errors="ignore"
|
|
853
|
+
)
|
|
854
|
+
else:
|
|
855
|
+
print(f" MISS solarized layer file ({path.as_posix()})")
|
|
856
|
+
fail += 1
|
|
857
|
+
hill_tokens = PALETTE_ROOT / "hill-light" / "tokens.css"
|
|
858
|
+
hill_syntax = PALETTE_ROOT / "hill-light" / "syntax.css"
|
|
859
|
+
hill_texts = {}
|
|
860
|
+
for path in (hill_tokens, hill_syntax):
|
|
861
|
+
if path.is_file():
|
|
862
|
+
hill_texts[path.name] = path.read_text(encoding="utf-8", errors="ignore")
|
|
863
|
+
else:
|
|
864
|
+
print(f" MISS hill-light layer file ({path.as_posix()})")
|
|
865
|
+
fail += 1
|
|
866
|
+
for texts, path, layer_checks in (
|
|
867
|
+
(solarized_texts, solarized_tokens, SOLARIZED_TOKEN_CHECKS),
|
|
868
|
+
(solarized_texts, solarized_syntax, SOLARIZED_SYNTAX_CHECKS),
|
|
869
|
+
(hill_texts, hill_tokens, HILL_LIGHT_TOKEN_CHECKS),
|
|
870
|
+
(hill_texts, hill_syntax, HILL_LIGHT_SYNTAX_CHECKS),
|
|
871
|
+
):
|
|
872
|
+
text = texts.get(path.name)
|
|
873
|
+
if text is None:
|
|
874
|
+
continue
|
|
875
|
+
for name, pattern in layer_checks:
|
|
876
|
+
found = pattern in text
|
|
877
|
+
mark = "OK " if found else "MISS"
|
|
878
|
+
print(f" {mark} {name} ({pattern})")
|
|
879
|
+
if not found:
|
|
880
|
+
fail += 1
|
|
881
|
+
|
|
882
|
+
fail += check_hill_light_modes(hill_texts.get("tokens.css", ""))
|
|
883
|
+
|
|
641
884
|
callouts_css = (
|
|
642
885
|
THEME_DIR / "assets" / "theme" / "css" / "callouts.css"
|
|
643
886
|
).read_text(encoding="utf-8")
|
|
@@ -661,6 +904,21 @@ def main():
|
|
|
661
904
|
if found:
|
|
662
905
|
fail += 1
|
|
663
906
|
|
|
907
|
+
contrast_failures = check_palette_contrast()
|
|
908
|
+
print(f" {'MISS' if contrast_failures else 'OK '} palette text contrast >= 4.5:1")
|
|
909
|
+
if contrast_failures:
|
|
910
|
+
for line in contrast_failures:
|
|
911
|
+
print(f" {line}")
|
|
912
|
+
fail += 1
|
|
913
|
+
|
|
914
|
+
css_defined = set(re.findall(r"(--[a-z0-9-]+)\s*:", theme_text))
|
|
915
|
+
css_used = set(re.findall(r"var\(\s*(--[a-z0-9-]+)", theme_text))
|
|
916
|
+
undefined_tokens = sorted(css_used - css_defined)
|
|
917
|
+
print(f" {'MISS' if undefined_tokens else 'OK '} css tokens all defined")
|
|
918
|
+
if undefined_tokens:
|
|
919
|
+
print(" " + ", ".join(undefined_tokens))
|
|
920
|
+
fail += 1
|
|
921
|
+
|
|
664
922
|
legacy_font_files = sorted(
|
|
665
923
|
p.name
|
|
666
924
|
for pattern in ("inter-*", "noto-sans-sc-*", "jetbrains-mono-*")
|
|
@@ -715,7 +973,7 @@ def main():
|
|
|
715
973
|
else:
|
|
716
974
|
print(
|
|
717
975
|
f" {'OK ' if not browser_problems else 'MISS'} browser-layout"
|
|
718
|
-
f" (viewports=1280,768,390; schemes=light,dark; "
|
|
976
|
+
f" (viewports=1280,768,390,360; schemes=light,dark; "
|
|
719
977
|
f"mode={browser_info.get('mode', 'n/a')}; "
|
|
720
978
|
f"pages={browser_info.get('pages', 0)})"
|
|
721
979
|
)
|
|
@@ -732,7 +990,7 @@ def main():
|
|
|
732
990
|
print(
|
|
733
991
|
"PASS theme-static;browser-layout "
|
|
734
992
|
f"{browser_info.get('mode', 'full')} "
|
|
735
|
-
f"{browser_info.get('pages', 0)} pages × 1280/768/390 × light/dark"
|
|
993
|
+
f"{browser_info.get('pages', 0)} pages × 1280/768/390/360 × light/dark"
|
|
736
994
|
)
|
|
737
995
|
return 0
|
|
738
996
|
print(f"{fail} theme check(s) failed; inspect the diagnostics above.")
|
|
@@ -20,7 +20,7 @@ const bookDir = resolve(flag("--book-dir", "_book"));
|
|
|
20
20
|
const outFile = flag("--out", "");
|
|
21
21
|
const shotsDir = flag("--shots-dir", "");
|
|
22
22
|
const allPages = argv.includes("--all-pages");
|
|
23
|
-
const viewports = [1280, 768, 390];
|
|
23
|
+
const viewports = [1280, 768, 390, 360];
|
|
24
24
|
const colorSchemes = ["light", "dark"];
|
|
25
25
|
|
|
26
26
|
async function collectHtml(directory) {
|
|
@@ -152,6 +152,9 @@ async function collect(page, pageId, scheme) {
|
|
|
152
152
|
for (const element of scope.querySelectorAll("*")) {
|
|
153
153
|
if (
|
|
154
154
|
element.closest(".screen-reader-only, .sr-only, [aria-hidden='true']")
|
|
155
|
+
// 内联 SVG(棋盘快照等)没有 CSS 盒模型,<text> 的 clientWidth 恒为 0、
|
|
156
|
+
// scrollWidth 按字形量出,天然「溢出」,不能当版式问题。
|
|
157
|
+
|| element.closest("svg")
|
|
155
158
|
|| style(element).display === "none"
|
|
156
159
|
|| style(element).visibility === "hidden"
|
|
157
160
|
) {
|
|
@@ -160,13 +163,22 @@ async function collect(page, pageId, scheme) {
|
|
|
160
163
|
if (!element.clientWidth || element.scrollWidth <= element.clientWidth + 1) {
|
|
161
164
|
continue;
|
|
162
165
|
}
|
|
163
|
-
|
|
166
|
+
// 宽于容器的元素只有整页横向滚动条才算问题:代码行本身宽于 <pre> 是常态,
|
|
167
|
+
// <pre> 自己 overflow-x: auto 就不该上报。所以沿祖先链找真正的滚动容器。
|
|
168
|
+
let scrolls = false;
|
|
169
|
+
for (let node = element; node && node !== document.body; node = node.parentElement) {
|
|
170
|
+
const overflowX = style(node).overflowX;
|
|
171
|
+
if (overflowX === "auto" || overflowX === "scroll") {
|
|
172
|
+
scrolls = true;
|
|
173
|
+
break;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
164
176
|
overflow.push({
|
|
165
177
|
tag: element.tagName.toLowerCase(),
|
|
166
178
|
classes: typeof element.className === "string" ? element.className.slice(0, 80) : "",
|
|
167
179
|
scrollWidth: element.scrollWidth,
|
|
168
180
|
clientWidth: element.clientWidth,
|
|
169
|
-
scrolls
|
|
181
|
+
scrolls,
|
|
170
182
|
inPre: Boolean(element.closest("pre")),
|
|
171
183
|
});
|
|
172
184
|
}
|
|
@@ -183,6 +195,29 @@ async function collect(page, pageId, scheme) {
|
|
|
183
195
|
borderRightWidth: px(style(sidebar).borderRightWidth),
|
|
184
196
|
}
|
|
185
197
|
: null;
|
|
198
|
+
// 栏间分隔只该有顶栏下那一条:读 #quarto-header 的底边,和有没有 h2 短伪元素线。
|
|
199
|
+
// 三个数据都来自计算样式,不依赖主题里写了什么,改回旧规则会直接失败。
|
|
200
|
+
const header = document.querySelector("#quarto-header");
|
|
201
|
+
const headerRule = header
|
|
202
|
+
? {
|
|
203
|
+
borderBottomWidth: px(style(header).borderBottomWidth),
|
|
204
|
+
borderBottomColor: style(header).borderBottomColor,
|
|
205
|
+
secondaryBorderBottomWidth: px(
|
|
206
|
+
style(document.querySelector("nav.quarto-secondary-nav") || header).borderBottomWidth
|
|
207
|
+
),
|
|
208
|
+
}
|
|
209
|
+
: null;
|
|
210
|
+
const h2Rules = [...document.querySelectorAll("#quarto-document-content h2")]
|
|
211
|
+
.map((heading) => {
|
|
212
|
+
const after = window.getComputedStyle(heading, "::after");
|
|
213
|
+
return {
|
|
214
|
+
text: heading.textContent.trim().slice(0, 30),
|
|
215
|
+
afterContent: after.content,
|
|
216
|
+
afterWidth: px(after.width),
|
|
217
|
+
afterHeight: px(after.height),
|
|
218
|
+
};
|
|
219
|
+
})
|
|
220
|
+
.filter((item) => item.afterContent && item.afterContent !== "none" && item.afterHeight > 0);
|
|
186
221
|
// 折叠状态来自构建期写入的 expanded:顶层 part 以 depth=1 渲染,
|
|
187
222
|
// collapse-level 决定哪一层默认展开。断言只看 show 类,不看动画中间态。
|
|
188
223
|
const sidebarSections = sidebar
|
|
@@ -224,6 +259,8 @@ async function collect(page, pageId, scheme) {
|
|
|
224
259
|
copyButtonOpacities,
|
|
225
260
|
sidebarOverlay,
|
|
226
261
|
sidebarSections,
|
|
262
|
+
headerRule,
|
|
263
|
+
h2Rules,
|
|
227
264
|
contentWidth: px(rootStyle.width),
|
|
228
265
|
color: rootStyle.color,
|
|
229
266
|
backgroundColor: style(document.body).backgroundColor,
|
|
@@ -12,8 +12,8 @@
|
|
|
12
12
|
|
|
13
13
|
| 选项 | 取值 | 备注 |
|
|
14
14
|
|---|---|---|
|
|
15
|
-
| `theme` | `light: [cosmo, palettes/
|
|
16
|
-
| `highlight-style` | `light:`/`dark:` 指向 `palettes/
|
|
15
|
+
| `theme` | `light: [cosmo, theme/palettes/flexoki/theme-light.scss]` + `dark: [darkly, theme/palettes/flexoki/theme-dark.scss]` | 内置主题与项目样式叠加,顺序决定覆盖关系;Flexoki 是编译默认 |
|
|
16
|
+
| `highlight-style` | `light:`/`dark:` 指向 `theme/palettes/flexoki/highlight-light.theme` / `highlight-dark.theme` | 明暗分别指定,与 `palettes/flexoki/meta.md` 同步;Solarized 的语法色由 `theme/palettes/solarized/syntax.css` 在 `html[data-palette="solarized"]` 下覆盖,不改这里 |
|
|
17
17
|
| `filters` | `scripts/answering-disclosure.lua`(顶层 `filters:`) | 将正文 `.answer` 转为默认收起的原生 `details` |
|
|
18
18
|
| `toc` / `toc-depth` / `toc-location` | `true` / `3` / `right` | 右侧目录最多到 H3,窄屏会折叠,不作唯一定位手段 |
|
|
19
19
|
| `number-sections` | `false` | 因此标题不手填序号,见 `quarto/basics.md` |
|
|
@@ -18,8 +18,8 @@
|
|
|
18
18
|
否则移动端永远无法复制代码。
|
|
19
19
|
C3 打印不隐藏代码:@media print 内不得对 .code-copy-outer-scaffold 设 display:none。
|
|
20
20
|
(该层现在包裹代码本身,隐藏它 = 打印/PDF 时代码整块消失。)
|
|
21
|
-
C4 站点图标:每个 HTML 的 <head> 必须含 rel="icon" 且指向 favicon.svg
|
|
22
|
-
|
|
21
|
+
C4 站点图标:每个 HTML 的 <head> 必须含 rel="icon" 且指向 favicon-light.svg
|
|
22
|
+
或 favicon-dark.svg(编译期默认是 light),且两个色板的明暗图标都已发布。
|
|
23
23
|
C5 Mermaid 图表(按产物条件):产物没有 mermaid-js 占位符,说明正文本就不用图表,
|
|
24
24
|
契约 N/A。一旦出现占位符,就必须同时包含 Quarto Mermaid 运行时,且不得退化为
|
|
25
25
|
普通 sourceCode 代码块。浏览器加载脚本后,占位符会被转换成 SVG。
|
|
@@ -300,11 +300,17 @@ def check_contracts(book_dir, htmls, css_pairs, root=None, require_favicon=True)
|
|
|
300
300
|
missing = []
|
|
301
301
|
for path, _tree, link_rels in htmls:
|
|
302
302
|
rels = [(r, h) for r, h in link_rels if "icon" in r]
|
|
303
|
-
if any("favicon.svg" in h for r, h in rels):
|
|
303
|
+
if any("favicon-light.svg" in h or "favicon-dark.svg" in h for r, h in rels):
|
|
304
304
|
icon_pages += 1
|
|
305
305
|
else:
|
|
306
306
|
missing.append(str(path.relative_to(book_dir)))
|
|
307
|
-
|
|
307
|
+
icon_files = [p for p in book_dir.rglob("favicon-*.svg")]
|
|
308
|
+
icon_names = {p.name for p in icon_files}
|
|
309
|
+
icon_palettes = {p.parent.name for p in icon_files}
|
|
310
|
+
icon_file = (
|
|
311
|
+
{"favicon-light.svg", "favicon-dark.svg"} <= icon_names
|
|
312
|
+
and {"flexoki", "solarized"} <= icon_palettes
|
|
313
|
+
)
|
|
308
314
|
if not require_favicon:
|
|
309
315
|
results.append((
|
|
310
316
|
"C4", "站点图标已注入并发布", True,
|
|
@@ -313,15 +319,16 @@ def check_contracts(book_dir, htmls, css_pairs, root=None, require_favicon=True)
|
|
|
313
319
|
))
|
|
314
320
|
else:
|
|
315
321
|
c4 = icon_pages == len(htmls) and icon_file and bool(htmls)
|
|
316
|
-
summary = "%d/%d 页含 icon
|
|
322
|
+
summary = "%d/%d 页含 icon 链接,四份色板图标已发布=%s" % (icon_pages, len(htmls), icon_file)
|
|
317
323
|
if missing:
|
|
318
324
|
summary += ";缺链接: " + ", ".join(missing[:3])
|
|
319
325
|
if len(missing) > 3:
|
|
320
326
|
summary += " …"
|
|
321
327
|
results.append((
|
|
322
328
|
"C4", "站点图标已注入并发布", c4, summary,
|
|
323
|
-
["每页 <head> 需 <link rel=\"icon\" … favicon.svg
|
|
324
|
-
"
|
|
329
|
+
["每页 <head> 需 <link rel=\"icon\" … favicon-light.svg 或 favicon-dark.svg>;"
|
|
330
|
+
"四份源文件见 theme/palettes/{flexoki,solarized}/favicon-{light,dark}.svg,"
|
|
331
|
+
"book.favicon 指 Flexoki light,另外三份随 resources 发布"],
|
|
325
332
|
))
|
|
326
333
|
|
|
327
334
|
# ---------- C5 Mermaid ----------
|
|
@@ -1,4 +0,0 @@
|
|
|
1
|
-
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="文档">
|
|
2
|
-
<path fill="#000" fill-rule="evenodd" d="M17 7h22.5L50 17.5V57H17a5 5 0 0 1-5-5V12a5 5 0 0 1 5-5Zm22 4.8V19h7.2L39 11.8ZM17 12a0 0 0 0 0 0 0v40h28V24H35V12H17Z"/>
|
|
3
|
-
<path fill="#000" d="M22 31h18v4H22zm0 9h18v4H22z"/>
|
|
4
|
-
</svg>
|