slow.monster

STUDIO HAZE · jeniapp

Topaz source

Open by house policy. Topaz is our own language.

Topaz 5.20.2 · 63 files

apps/typeset/typeset.tpz

Topaz · 16210 B · Original file

1// 조판. 문단마다 최적 줄나눔을 하고 한국어 금칙과 양끝맞춤을 적용한 뒤 쪽으로 나눠 과부와 고아를 막습니다.
2// 입력 첫 줄은 줄 너비와 생략할 수 있는 쪽당 줄 수이고, 다음 줄부터 본문이며 빈 줄이 문단을 나눕니다.
3// 출력은 요약 한 줄과 쪽마다 SVG 상자 하나를 담은 HTML 조각입니다.
4// 같은 입력이면 인터프리터와 웹 빌드에서 바이트까지 같은 출력을 냅니다.
5// 비례 글꼴 너비, 하이픈 넣기, 서식 있는 글은 다루지 않습니다. 너비는 반각 1칸, 전각 2칸의 고정폭입니다.
6// Typesetter: optimal line breaking with Korean line-break rules, justification and widow/orphan-aware pagination.
8record 조각 { 글: string, 너비: int, 줄머리금칙: bool, 줄끝금칙: bool, 앞공백: bool }
9record 조각열 { 조각들: Array<조각>, 너비누적: Array<int>, 공백누적: Array<int> }
10record 줄 { 시작: int, 끝: int }
11record 문단배치 { 열: 조각열, 줄들: Array<줄>, 금칙완화: bool }
12record 놓인줄 { 문단: int, 범위: 줄, 순번: int, 문단줄수: int }
13record 쪽나눔 { 쪽들: Array<Array<놓인줄>>, 고아막음: int, 과부막음: int }
14record 설정 { 줄너비: int, 쪽높이: int, 높이보정: bool }
15type 글자종류 = "공백" | "전각" | "반각"
17let 무한 = 1000000000000.0
18let 칸너비 = 9.0
19let 줄높이 = 24.0
20let 줄머리금칙집합 = Set.of(")", "]", "\}", "」", "』", "›", "》", ",", ".", "?", "!", ";", ":", "。", "、", ",", ".", "?", "!", ":", ";", "”", "’")
21let 줄끝금칙집합 = Set.of("(", "[", "\{", "「", "『", "‹", "《", "“", "‘", "(")
22let 빈본문안내 = "<div class=\"meta\">문단을 입력하면 최적 줄나눔과 페이지 배치로 보여줍니다.</div>"
24// 공용 도우미 시작 (이스케이프 공백인가 전각인가 글자너비 숫자인가 모두숫자인가 오류알림 정수오류 자연수읽기 결과풀기 오류바꾸기)
25// 여러 도구가 같은 원본을 씁니다. 여기서 고치지 않고 원본에서 고친 뒤 다시 생성합니다.
27// 사용자 입력을 HTML 에 넣기 전에 반드시 거칩니다.
28function 이스케이프(원문: string) -> string {
29 원문.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace("\"", "&quot;")
32function 공백인가(글자: string) -> bool {
33 글자 == " " || 글자 == "\t" || 글자 == "\n" || 글자 == "\r"
36// 고정폭에서 두 칸을 차지하는 범위입니다. 한글 음절, 호환 자모, 자모, 한자, CJK 문장부호, 전각 영숫자 순입니다.
37// 코드포인트를 10진수로 적습니다. 토파즈 과제 '16진 정수 리터럴' 의 우회입니다.
38function 전각인가(글자: string) -> bool {
39 let 코드 = 글자.codePointAt(0) ?? 0
40 (코드 >= 44032 && 코드 <= 55203)
41 || (코드 >= 12592 && 코드 <= 12687)
42 || (코드 >= 4352 && 코드 <= 4607)
43 || (코드 >= 19968 && 코드 <= 40959)
44 || (코드 >= 12288 && 코드 <= 12351)
45 || (코드 >= 65280 && 코드 <= 65519)
48function 글자너비(글자: string) -> int {
49 if 전각인가(글자) { 2 } else { 1 }
52function 숫자인가(글자: string) -> bool {
53 let 코드 = 글자.codePointAt(0) ?? 0
54 코드 >= 48 && 코드 <= 57
57// 빈 문자열은 참입니다. 부호와 소수점을 받지 않으려고 toInt 앞에서 씁니다.
58function 모두숫자인가(글: string) -> bool {
59 글.scalars().reduce(true, (모두, 글자) => 모두 && 숫자인가(글자))
62// 문구는 고정 문장이거나 이미 이스케이프한 값이어야 합니다.
63function 오류알림(문구: string) -> string {
64 "<div class=\"err\">{문구}</div>"
67// 정수 읽기가 실패한 까닭입니다. 보여 줄 문구는 앱이 정합니다.
68type 정수오류 = "숫자아님" | "너무큼"
70// 부호 없는 숫자만 받습니다. 빈 글은 0 입니다.
71function 자연수읽기(글: string, 최대자릿수: int) -> Result<int, 정수오류> {
72 if !모두숫자인가(글) { return Err("숫자아님") }
73 if 글.scalars().length > 최대자릿수 { return Err("너무큼") }
74 Ok(toInt(글) ?? 0)
77// 성공 값과 안내가 모두 바로 보여 줄 HTML 조각일 때 진입점에서 한 번 풉니다. 토파즈 과제 'Result 오류 쪽 변환과 풀기' 의 우회입니다.
78function 결과풀기(값: Result<string, string>) -> string {
79 match 값 {
80 case Ok(결과) => 결과
81 case Err(안내) => 안내
82 }
85// 성공 값은 그대로 두고 오류만 문구로 바꿉니다. 토파즈 과제 'Result 오류 쪽 변환과 풀기' 의 우회입니다.
86function 오류바꾸기<T, E>(결과: Result<T, E>, 바꾸기: (E) -> string) -> Result<T, string> {
87 match 결과 {
88 case Ok(값) => Ok(값)
89 case Err(오류) => Err(바꾸기(오류))
90 }
93// 공용 도우미 끝
95function 글자종류정하기(글자: string) -> 글자종류 {
96 if 공백인가(글자) { "공백" } else if 전각인가(글자) { "전각" } else { "반각" }
99// 이어진 반각 글자는 한 조각(낱말)이고 전각 글자는 글자마다 한 조각입니다. 공백은 다음 조각의 앞공백으로만 남습니다.
100function 조각시작인가(종류들: Array<글자종류>, i: int) -> bool {
101 종류들[i] == "전각" || (종류들[i] == "반각" && (i == 0 || 종류들[i - 1] != "반각"))
104function 조각끝(종류들: Array<글자종류>, 시작: int) -> int {
105 let mut 끝 = 시작
106 if 종류들[시작] == "반각" {
107 while 끝 + 1 < 종류들.length && 종류들[끝 + 1] == "반각" { 끝 += 1 }
108 }
109 끝
112function 조각만들기(글자들: Array<string>, 종류들: Array<글자종류>, 시작: int) -> 조각 {
113 let 끝 = 조각끝(종류들, 시작)
114 조각 {
115 글: 글자들.slice(시작, 끝 + 1).join(""),
116 너비: (끝 - 시작 + 1) * 글자너비(글자들[시작]),
117 줄머리금칙: 글자들[시작] in 줄머리금칙집합,
118 줄끝금칙: 글자들[끝] in 줄끝금칙집합,
119 앞공백: 시작 > 0 && 종류들[시작 - 1] == "공백",
120 }
123function 누적합(값들: Array<int>) -> Array<int> {
124 let mut 합들 = [0]
125 for 값 in 값들 { 합들.push(합들[합들.length - 1] + 값) }
126 합들
129function 조각열만들기(본문: string) -> 조각열 {
130 let 글자들 = 본문.scalars()
131 let 종류들 = 글자들.map(글자종류정하기)
132 let 조각들 = [for i in 0..<글자들.length if 조각시작인가(종류들, i) => 조각만들기(글자들, 종류들, i)]
133 조각열 {
134 조각들: 조각들,
135 너비누적: 누적합(조각들.map(항 => 항.너비)),
136 공백누적: 누적합(조각들.map(항 => if 항.앞공백 { 1 } else { 0 })),
137 }
140// 줄 첫 조각의 앞공백은 그리지 않으므로 세지 않습니다.
141function 줄폭(열: 조각열, 시작: int, 끝: int) -> int {
142 (열.너비누적[끝 + 1] - 열.너비누적[시작]) + (열.공백누적[끝 + 1] - 열.공백누적[시작 + 1])
145// 넘치는 줄은 모자란 줄보다 읽기 어려워 같은 차이에 8배를 매깁니다.
146function 나쁨도(목표: int, 폭: int) -> float {
147 let 차 = Math.abs(toFloat(폭 - 목표))
148 if 폭 > 목표 { 차 * 차 * 8.0 } else { 차 * 차 }
151// 최적 줄나눔. 최적[i] 는 조각 0..i-1 을 나누는 최소 나쁨도 합이고 부모[i] 는 그 마지막 줄의 첫 조각입니다.
152// 분할가능[k] 는 조각 k-1 과 k 사이에서 줄을 바꿔도 되는지입니다.
153function 줄나누기(열: 조각열, 분할가능: Array<bool>, 목표: int) -> Option<Array<줄>> {
154 let n = 열.조각들.length
155 let mut 최적 = [0.0]
156 let mut 부모 = [-1]
157 for i in 1..<(n + 1) {
158 let mut 최선 = 무한
159 let mut 최선부모 = -1
160 if 분할가능[i] {
161 // j 를 줄이며 줄을 넓힙니다. 같은 합이면 먼저 본 j 를 지켜야 출력이 바뀌지 않습니다.
162 let mut j = i - 1
163 let mut 넓힐수있음 = true
164 while 넓힐수있음 && j >= 0 {
165 let 폭 = 줄폭(열, j, i - 1)
166 // 줄 너비보다 넓은 조각 하나는 그것만으로 한 줄이 됩니다.
167 if (폭 <= 목표 || j == i - 1) && 최적[j] < 무한 {
168 // 문단의 마지막 줄은 왼쪽으로 흘리므로 나쁨도를 매기지 않습니다.
169 let 합 = 최적[j] + (if i == n { 0.0 } else { 나쁨도(목표, 폭) })
170 if 합 < 최선 { 최선 = 합; 최선부모 = j }
171 }
172 넓힐수있음 = 폭 <= 목표
173 j -= 1
174 }
175 }
176 최적.push(최선)
177 부모.push(최선부모)
178 }
179 // 금칙을 지키며 끝까지 갈 수 없으면 None 을 돌려 호출부가 금칙을 풀게 합니다.
180 if 최적[n] >= 무한 { return None }
181 let mut 줄들: Array<줄> = []
182 let mut 끝 = n
183 while 끝 > 0 {
184 줄들.push(줄 { 시작: 부모[끝], 끝: 끝 - 1 })
185 끝 = 부모[끝]
186 }
187 줄들.reverse()
188 Some(줄들)
191// 너비가 좁아 금칙을 지키는 줄나눔이 없으면 금칙을 풀고 다시 나눕니다.
192function 문단배치하기(본문: string, 목표: int) -> 문단배치 {
193 let 열 = 조각열만들기(본문)
194 let n = 열.조각들.length
195 let 금칙분할 = [for k in 0..<(n + 1) => k == 0 || k == n || (!열.조각들[k - 1].줄끝금칙 && !열.조각들[k].줄머리금칙)]
196 match 줄나누기(열, 금칙분할, 목표) {
197 case Some(줄들) => 문단배치 { 열: 열, 줄들: 줄들, 금칙완화: false }
198 case None => 문단배치 { 열: 열, 줄들: 줄나누기(열, [for k in 0..<(n + 1) => true], 목표) ?? [], 금칙완화: true }
199 }
202// 고아는 여러 줄 문단의 첫 줄이 쪽 맨 아래 칸에 혼자 남는 것, 과부는 마지막 줄이 다음 쪽 맨 위에 혼자 가는 것입니다.
203// 고아가 될 줄은 다음 쪽으로 넘기고, 끝에서 둘째 줄이 쪽을 채우면 그 줄을 다음 쪽으로 내려 마지막 줄과 붙입니다.
204function 쪽나누기(놓인줄들: Array<놓인줄>, 쪽높이: int) -> 쪽나눔 {
205 let mut 쪽들: Array<Array<놓인줄>> = []
206 let mut 현재쪽: Array<놓인줄> = []
207 let mut 고아막음 = 0
208 let mut 과부막음 = 0
209 for 놓인 in 놓인줄들 {
210 let 고아될줄 = 놓인.순번 == 0 && 놓인.문단줄수 >= 2 && 현재쪽.length == 쪽높이 - 1
211 if 현재쪽.length == 쪽높이 || 고아될줄 {
212 쪽들.push(현재쪽)
213 현재쪽 = []
214 }
215 if 고아될줄 { 고아막음 += 1 }
216 현재쪽.push(놓인)
217 if 놓인.순번 == 놓인.문단줄수 - 2 && 현재쪽.length == 쪽높이 {
218 쪽들.push(현재쪽.slice(0, 현재쪽.length - 1))
219 현재쪽 = [놓인]
220 과부막음 += 1
221 }
222 }
223 if 현재쪽.length > 0 { 쪽들.push(현재쪽) }
224 쪽나눔 { 쪽들: 쪽들, 고아막음: 고아막음, 과부막음: 과부막음 }
227// 양끝맞춤은 남는 폭을 조각 사이에 고르게 나눕니다. x 는 이 순서로 더해야 이전 출력과 같은 소수가 나옵니다.
228function 줄그리기(열: 조각열, 놓인: 놓인줄, 목표: int, y: float) -> string {
229 let 시작 = 놓인.범위.시작
230 let 끝 = 놓인.범위.끝
231 let 사이수 = 끝 - 시작
232 let 자연폭 = 줄폭(열, 시작, 끝)
233 let 마지막줄 = 놓인.순번 == 놓인.문단줄수 - 1
234 let 늘림 = if !마지막줄 && 사이수 > 0 && 자연폭 < 목표 { toFloat(목표 - 자연폭) / toFloat(사이수) } else { 0.0 }
235 let mut 글들: Array<string> = []
236 let mut x = 0.0
237 for k in 시작..<(끝 + 1) {
238 let 항 = 열.조각들[k]
239 if k > 시작 { x += ((if 항.앞공백 { 1.0 } else { 0.0 }) + 늘림) * 칸너비 }
240 글들.push("<text x=\"{x}\" y=\"{y}\" font-size=\"15\" font-family=\"monospace\" fill=\"#111\">{이스케이프(항.글)}</text>")
241 x += toFloat(항.너비) * 칸너비
242 }
243 글들.join("")
246function 쪽그리기(쪽: Array<놓인줄>, 배치들: Array<문단배치>, 목표: int, 쪽높이: int, 쪽번호: int) -> string {
247 let 본문폭 = toFloat(목표) * 칸너비 + 2.0
248 let 상자높이 = toFloat(쪽높이) * 줄높이 + 28.0
249 let 줄그림들 = map(0..<쪽.length, 칸 => 줄그리기(배치들[쪽[칸].문단].열, 쪽[칸], 목표, toFloat(칸) * 줄높이 + 38.0))
250 let 머리 = [
251 "<svg viewBox=\"0 0 {본문폭} {상자높이}\" width=\"100%\" style=\"border:1px solid #bbb;margin-top:10px;display:block\">",
252 "<text x=\"2\" y=\"14\" font-size=\"11\" font-family=\"monospace\" fill=\"#888\">{쪽번호}페이지</text>",
253 "<line x1=\"{본문폭 - 2.0}\" y1=\"20\" x2=\"{본문폭 - 2.0}\" y2=\"{상자높이}\" stroke=\"#ddd\" />",
254 ]
255 머리.join("") + 줄그림들.join("") + "</svg>"
258function 설정수문구(종류: 정수오류, 이름: string) -> string {
259 match 종류 {
260 case "숫자아님" => 오류알림("{이름}는 양의 정수여야 합니다.")
261 case "너무큼" => 오류알림("{이름}가 너무 큽니다.")
262 }
265function 설정수읽기(글: string, 이름: string, 최대자릿수: int) -> Result<int, string> {
266 오류바꾸기(자연수읽기(글, 최대자릿수), 종류 => 설정수문구(종류, 이름))
269function 설정읽기(첫줄: string) -> Result<설정, string> {
270 if 첫줄 == "" {
271 return Err("<div class=\"meta\">첫 줄에 줄 너비와 선택 페이지 줄 수를 적고, 다음 줄부터 문단을 입력하세요. 문단은 빈 줄로 구분합니다.</div>")
272 }
273 let 토큰들 = 첫줄.split(" ").filter(토큰 => 토큰 != "")
274 if 토큰들.length > 2 { return Err(오류알림("첫 줄은 줄 너비와 선택 페이지 줄 수, 최대 두 개입니다.")) }
275 let 줄너비 = 설정수읽기(토큰들[0], "줄 너비", 4)?
276 if 줄너비 < 1 { return Err(오류알림("줄 너비는 1 이상이어야 합니다.")) }
277 let 요청높이 = if 토큰들.length == 2 { 설정수읽기(토큰들[1], "페이지 줄 수", 3)? } else { 6 }
278 let 쪽높이 = if 요청높이 < 2 { 2 } else if 요청높이 > 30 { 30 } else { 요청높이 }
279 Ok(설정 { 줄너비: 줄너비, 쪽높이: 쪽높이, 높이보정: 요청높이 < 2 })
282// 빈 줄이 문단을 나누고 한 문단의 여러 줄은 공백 하나로 잇습니다.
283function 문단나누기(줄목록: Array<string>) -> Array<string> {
284 let mut 문단들: Array<string> = []
285 let mut 모은줄: Array<string> = []
286 for 입력줄 in 줄목록 {
287 if 입력줄.trim() != "" {
288 모은줄.push(입력줄)
289 } else if 모은줄.length > 0 {
290 문단들.push(모은줄.join(" "))
291 모은줄 = []
292 }
293 }
294 if 모은줄.length > 0 { 문단들.push(모은줄.join(" ")) }
295 문단들
298// 줄나눔 비용은 조각 수와 함께 커지므로 입력 길이를 제한합니다.
299function 조판(입력: string) -> Result<string, string> {
300 if 입력.scalars().length > 12000 { return Err(오류알림("입력이 너무 깁니다. 12000자 이하로 줄여주세요.")) }
301 let 줄목록 = 입력.split("\n")
302 let 설정값 = 설정읽기(줄목록[0].trim())?
303 let 배치들 = 문단나누기(줄목록.slice(1, 줄목록.length)).map(문단 => 문단배치하기(문단, 설정값.줄너비))
304 let 놓인줄들 = [for 문단 in 0..<배치들.length for 순번 in 0..<배치들[문단].줄들.length => 놓인줄 { 문단: 문단, 범위: 배치들[문단].줄들[순번], 순번: 순번, 문단줄수: 배치들[문단].줄들.length }]
305 if 놓인줄들.length == 0 { return Err(빈본문안내) }
306 let 나눔 = 쪽나누기(놓인줄들, 설정값.쪽높이)
307 let 요약 = [
308 "{나눔.쪽들.length}페이지, 너비 {설정값.줄너비}칸, 페이지당 {설정값.쪽높이}줄. 고아 {나눔.고아막음}건과 과부 {나눔.과부막음}건을 방지했습니다",
309 if 설정값.높이보정 { ". 페이지 줄 수는 최소 2이므로 2로 맞췄습니다" } else { "" },
310 if 배치들.filter(배치 => 배치.금칙완화).length > 0 { ". 폭이 좁아 일부 문단에서 금칙을 완화했습니다" } else { "" },
311 ]
312 let 그림 = map(0..<나눔.쪽들.length, 쪽번 => 쪽그리기(나눔.쪽들[쪽번], 배치들, 설정값.줄너비, 설정값.쪽높이, 쪽번 + 1))
313 Ok("<div class=\"meta\">" + 요약.join("") + "</div>" + 그림.join(""))
316input() |> 조판 |> 결과풀기 |> print

SHA256 e29299db9679326556c0b6a52bf3a5f6b13602de94b527fcf424a93446a6022c