← 제니앱

조판

여러 문단을 Knuth-Plass 최적 줄나눔으로 배치하고 페이지로 나눠 과부와 고아를 방지합니다. 한국어 금칙을 지키며 각 문단의 마지막 줄을 뺀 모든 줄을 양끝맞춤합니다. 폭은 고정폭으로 라틴 글자는 1칸, 한글은 2칸입니다. 문단은 빈 줄로 구분합니다.

typeset.tpz
// 여러 문단을 Knuth-Plass 최적 줄나눔으로 배치하고, 페이지로 나눠 과부와 고아를 방지한다.
// 한국어 금칙(줄 처음/끝 금지 문자)을 지키고 각 문단의 마지막 줄을 뺀 모든 줄을 양끝맞춤해
// 페이지마다 SVG 한 상자로 그린다.
// Multi-paragraph typesetter. Optimal (Knuth-Plass) line breaking per paragraph with Korean
// line-break prohibition (금칙), pagination with widow/orphan (과부/고아) control, justification of
// every line but each paragraph's last, rendered as one SVG box per page.
//
// 폭 모델은 고정폭이다: 라틴/공백 1칸, 한글/CJK 2칸. 폰트 엔진을 쓰지 않는다(비례 폰트 메트릭은
// 범위 밖). 배드니스 계산에 toFloat를 쓴다(이 앱이 견인한 기능).
// Width model is fixed-width: Latin/space = 1 unit, Hangul/CJK = 2 units. No font engine
// (proportional metrics are out of scope). Badness math uses toFloat (the feature this app drives).
//
// 과부/고아 규칙: 한 문단의 첫 줄을 페이지 맨 아래 한 칸에 혼자 두지 않는다(고아). 한 문단의 마지막
// 줄을 다음 페이지 맨 위에 혼자 두지 않도록 앞 줄을 함께 내린다(과부). 두 줄 이상인 문단에만 쓴다.
// Widow/orphan: never leave a paragraph's first line alone in a page's last slot (orphan); never
// leave a paragraph's last line alone atop the next page (widow), pulling the previous line down.
// Applied to paragraphs of two or more lines only.
//
// Key identifiers: 조각=piece, 텍스트=text, 너비=width, 못시작=cannotStartLine, 못끝=cannotEndLine,
//   앞공백=spaceBefore, 조각내기=splitPieces, 분할가능=breakAllowed, 라인너비=lineWidth,
//   배드니스=badness, 최적=best, 부모=parent, 줄=line, 양끝맞춤=justify, 단위=unitPx,
//   이스케이프=escape, 시제이케이=isCJK, 너비글=charWidth, 금칙무시=ignoredRules,
//   문단처리=layOutParagraph, 문단결과=paragraphResult, 배치줄=placedLine, 위치=indexInParagraph,
//   문단줄수=paragraphLineCount, 페이지높이=linesPerPage, 고아막음=orphansPrevented,
//   과부막음=widowsPrevented, 앞부분=dropLast, 페이지그리기=drawPage, 전체그림=drawAllPages
//
// 결정적이다. 같은 입력이면 인터프리터와 네이티브/wasm 빌드에서 같은 SVG가 나온다.
// Deterministic: same input yields the same SVG across interpreter and builds.
// 안 하는 것: 비례 폰트 메트릭, 하이프네이션, 리치텍스트.
// Won't do: proportional font metrics, hyphenation, rich text.

type 조각 = { 텍스트: string, 너비: int, 못시작: bool, 못끝: bool, 앞공백: bool }
type 나눔이유 = "none" | "full" | "orphan"

let 무한 = 1000000000000.0

function 나눔이유정하기(꽉참: bool, 고아조건: bool) -> 나눔이유 {
  if 꽉참 { "full" } else if 고아조건 { "orphan" } else { "none" }
}

// HTML 이스케이프. 본문은 사용자 입력이라 출력 전에 반드시 이스케이프한다.
// HTML escape. The paragraph is user input, so always escape before output.
function 이스케이프(원문: string) -> string {
  let mut 결과 = ""
  for 글 in 원문.scalars() {
    if 글 == "&" { 결과 = 결과 + "&" }
    else if 글 == "<" { 결과 = 결과 + "&lt;" }
    else if 글 == ">" { 결과 = 결과 + "&gt;" }
    else if 글 == "\"" { 결과 = 결과 + "&quot;" }
    else { 결과 = 결과 + 글 }
  }
  결과
}

// 숫자(0~9) 글자인가.
// Is the scalar an ASCII digit (0-9)?
function 숫자글(글: string) -> bool {
  let 코드 = 글.codePointAt(0) ?? 0
  코드 >= 48 && 코드 <= 57
}

// CJK(한글 음절/자모, 한자, CJK 문장부호 등)인가. 고정폭에서 2칸을 차지한다.
// Is the scalar CJK (Hangul, CJK ideographs, CJK punctuation)? It takes 2 fixed-width units.
function 시제이케이(글: string) -> bool {
  let 코드 = 글.codePointAt(0) ?? 0
  (코드 >= 44032 && 코드 <= 55203)      // 한글 음절 / Hangul syllables
    || (코드 >= 12592 && 코드 <= 12687) // 한글 호환 자모 / Hangul compatibility jamo
    || (코드 >= 4352 && 코드 <= 4607)   // 한글 자모 / Hangul jamo
    || (코드 >= 19968 && 코드 <= 40959) // CJK 한자 / CJK ideographs
    || (코드 >= 12288 && 코드 <= 12351) // CJK 기호/문장부호 / CJK symbols & punctuation
    || (코드 >= 65280 && 코드 <= 65519) // 전각 영숫자 / fullwidth forms
}

// 한 글자의 고정폭 너비.
// Fixed-width width of one scalar.
function 너비글(글: string) -> int {
  if 시제이케이(글) { 2 } else { 1 }
}

// 줄 처음에 올 수 없는 문자(금칙): 닫는 괄호류와 뒤따르는 문장부호.
// Cannot begin a line (금칙): closing brackets and trailing punctuation.
function 못시작글(글: string) -> bool {
  글 == ")" || 글 == "]" || 글 == "\}" || 글 == "」" || 글 == "』" || 글 == "›" || 글 == "》"
    || 글 == "," || 글 == "." || 글 == "?" || 글 == "!" || 글 == ";" || 글 == ":"
    || 글 == "。" || 글 == "、" || 글 == "," || 글 == "." || 글 == "?" || 글 == "!"
    || 글 == ":" || 글 == ";" || 글 == "”" || 글 == "’"
}

// 줄 끝에 올 수 없는 문자(금칙): 여는 괄호류.
// Cannot end a line (금칙): opening brackets.
function 못끝글(글: string) -> bool {
  글 == "(" || 글 == "[" || 글 == "\{" || 글 == "「" || 글 == "『" || 글 == "‹" || 글 == "《"
    || 글 == "“" || 글 == "‘" || 글 == "("
}

// 본문을 조각들로 나눈다. 라틴 낱말은 한 조각, CJK 글자는 글자마다 한 조각이다. 공백은 글루로,
// 조각의 앞공백 표시로만 남긴다(줄 끝의 공백은 그릴 때 버린다).
// Split text into pieces: a Latin word is one piece, each CJK char is its own piece. Spaces become
// glue, recorded as a piece's 앞공백 flag (a trailing space at a break is dropped when drawing).
function 조각내기(본문: string) -> Array<조각> {
  let mut 조각들: Array<조각> = []
  let mut 라틴 = ""
  let mut 라틴너비 = 0
  let mut 라틴앞공백 = false
  let mut 라틴첫 = ""
  let mut 라틴끝 = ""
  let mut 공백대기 = false
  for 글 in 본문.scalars() {
    let 코드 = 글.codePointAt(0) ?? 0
    let 공백 = 코드 == 32 || 코드 == 9 || 코드 == 10 || 코드 == 13
    if 공백 {
      if 라틴 != "" {
        조각들.push({ 텍스트: 라틴, 너비: 라틴너비, 못시작: 못시작글(라틴첫), 못끝: 못끝글(라틴끝), 앞공백: 라틴앞공백 })
        라틴 = ""; 라틴너비 = 0
      }
      공백대기 = true
    } else if 시제이케이(글) {
      if 라틴 != "" {
        조각들.push({ 텍스트: 라틴, 너비: 라틴너비, 못시작: 못시작글(라틴첫), 못끝: 못끝글(라틴끝), 앞공백: 라틴앞공백 })
        라틴 = ""; 라틴너비 = 0
      }
      조각들.push({ 텍스트: 글, 너비: 2, 못시작: 못시작글(글), 못끝: 못끝글(글), 앞공백: 공백대기 })
      공백대기 = false
    } else {
      if 라틴 == "" { 라틴앞공백 = 공백대기; 라틴첫 = 글; 공백대기 = false }
      라틴 = 라틴 + 글
      라틴너비 = 라틴너비 + 1
      라틴끝 = 글
    }
  }
  if 라틴 != "" {
    조각들.push({ 텍스트: 라틴, 너비: 라틴너비, 못시작: 못시작글(라틴첫), 못끝: 못끝글(라틴끝), 앞공백: 라틴앞공백 })
  }
  조각들
}

// 줄 너비: 조각 a..b의 너비 합 + 내부 공백(앞공백) 칸. 줄 처음의 공백은 버린다.
// Line width: sum of piece widths a..b plus interior space units. A leading space is dropped.
function 라인너비(조각들: Array<조각>, 너비누적: Array<int>, 공백누적: Array<int>, 에이: int, 비: int) -> int {
  let 폭합 = 너비누적[비 + 1] - 너비누적[에이]
  let 공백수 = 공백누적[비 + 1] - 공백누적[에이 + 1]
  폭합 + 공백수
}

// 배드니스: 목표에서 벗어난 만큼의 제곱(작을수록 좋다). float로 계산해 누적한다.
// Badness: squared deviation from the target (smaller is better). Computed and summed as float.
function 배드니스(목표: int, 폭: int) -> float {
  let 차 = if 폭 > 목표 { 폭 - 목표 } else { 목표 - 폭 }
  let 차에프 = toFloat(차)
  if 폭 > 목표 { 차에프 * 차에프 * 8.0 } else { 차에프 * 차에프 }
}

// 줄 끝(글자열) 결과: 줄마다 (시작조각, 끝조각).
// Line spans: each line is (firstPiece, lastPiece).
type 줄 = { 시작: int, 끝: int }

// Knuth-Plass DP. 분할가능[k] = 조각 k-1과 k 사이에서 줄을 바꿔도 되는가(금칙 반영). 양끝(0, n)은 참.
// Knuth-Plass DP. 분할가능[k] = may break between piece k-1 and k (respects 금칙). Ends (0, n) true.
function 줄나누기(조각들: Array<조각>, 너비누적: Array<int>, 공백누적: Array<int>, 분할가능: Array<bool>, 목표: int) -> Array<줄> {
  let 엔 = 조각들.length
  let mut 최적: Array<float> = []
  let mut 부모: Array<int> = []
  let mut 채움 = 0
  while 채움 <= 엔 { 최적.push(무한); 부모.push(-1); 채움 = 채움 + 1 }
  최적[0] = 0.0
  let mut 아이 = 1
  while 아이 <= 엔 {
    // 줄의 끝 끊기(조각 아이-1 다음)가 합법이어야 한다.
    // The end break (after piece 아이-1) must be legal.
    if 분할가능[아이] {
      let mut 제이 = 아이 - 1
      let mut 계속 = true
      while 제이 >= 0 && 계속 {
        let 폭 = 라인너비(조각들, 너비누적, 공백누적, 제이, 아이 - 1)
        let 단일 = 아이 - 1 == 제이
        if 폭 > 목표 && !단일 {
          계속 = false
        } else {
          if 최적[제이] < 무한 {
            let 마지막 = 아이 == 엔
            let mut 비용 = 0.0
            if !마지막 { 비용 = 배드니스(목표, 폭) }
            let 합 = 최적[제이] + 비용
            if 합 < 최적[아이] { 최적[아이] = 합; 부모[아이] = 제이 }
          }
          if 폭 > 목표 && 단일 { 계속 = false }
        }
        제이 = 제이 - 1
      }
    }
    아이 = 아이 + 1
  }
  // 끝에 도달하는 합법 경로가 없으면(금칙 때문에 폭이 너무 좁음) 빈 결과로 알려 호출부가 금칙을 완화하게 한다.
  // If no legal path reaches the end (금칙 makes the width infeasible) return empty so the caller relaxes 금칙.
  if 최적[엔] >= 무한 {
    let mut 없음: Array<줄> = []
    return 없음
  }
  // 역추적. 부모를 따라 줄 경계를 모은다.
  // Backtrack: collect line spans by following parents.
  let mut 뒤집힘: Array<줄> = []
  let mut 끝 = 엔
  while 끝 > 0 {
    let 시 = 부모[끝]
    뒤집힘.push({ 시작: 시, 끝: 끝 - 1 })
    끝 = 시
  }
  let mut 줄들: Array<줄> = []
  let mut 인덱스 = 뒤집힘.length - 1
  while 인덱스 >= 0 { 줄들.push(뒤집힘[인덱스]); 인덱스 = 인덱스 - 1 }
  줄들
}

// 한 문단의 줄나눔 결과: 조각들과 줄 경계, 금칙 완화 여부.
// One paragraph's layout: its pieces, line spans, and whether 금칙 was relaxed.
type 문단결과 = { 조각들: Array<조각>, 줄들: Array<줄>, 금칙무시: bool }

// 한 문단을 조각내고 Knuth-Plass로 줄을 나눈다. 폭이 좁아 금칙을 다 지킬 수 없으면 금칙을 풀고 다시 나눈다.
// Lay out one paragraph: split into pieces and break lines (Knuth-Plass). If the width is too narrow
// for 금칙, relax 금칙 and retry.
function 문단처리(문단본문: string, 목표: int) -> 문단결과 {
  let 조각들 = 조각내기(문단본문)
  if 조각들.length == 0 {
    let mut 빈줄: Array<줄> = []
    return { 조각들: 조각들, 줄들: 빈줄, 금칙무시: false }
  }
  let 엔 = 조각들.length
  let mut 너비누적: Array<int> = []
  let mut 공백누적: Array<int> = []
  너비누적.push(0); 공백누적.push(0)
  let mut 채움 = 0
  while 채움 < 엔 {
    너비누적.push(너비누적[채움] + 조각들[채움].너비)
    let mut 더 = 0
    if 조각들[채움].앞공백 { 더 = 1 }
    공백누적.push(공백누적[채움] + 더)
    채움 = 채움 + 1
  }
  let mut 분할가능: Array<bool> = []
  let mut 비 = 0
  while 비 <= 엔 {
    if 비 == 0 || 비 == 엔 { 분할가능.push(true) }
    else { 분할가능.push(!조각들[비 - 1].못끝 && !조각들[비].못시작) }
    비 = 비 + 1
  }
  let mut 줄들 = 줄나누기(조각들, 너비누적, 공백누적, 분할가능, 목표)
  let mut 금칙무시 = false
  if 줄들.length == 0 {
    let mut 모두가능: Array<bool> = []
    let mut 시 = 0
    while 시 <= 엔 { 모두가능.push(true); 시 = 시 + 1 }
    줄들 = 줄나누기(조각들, 너비누적, 공백누적, 모두가능, 목표)
    금칙무시 = true
  }
  { 조각들: 조각들, 줄들: 줄들, 금칙무시: 금칙무시 }
}

// 페이지에 놓인 한 줄. 어느 문단의 몇 번째 줄인지(과부/고아 판정용)와 줄 경계를 담는다.
// A line placed on a page: which paragraph and its index in that paragraph (for widow/orphan), plus its span.
type 배치줄 = { 문단: int, 시작: int, 끝: int, 위치: int, 문단줄수: int }

// 배열의 마지막 원소를 뺀 새 배열(과부 방지에서 끝앞 줄을 다음 페이지로 내릴 때 쓴다).
// A new array without the last element (used when the widow rule moves the next-to-last line down).
function 앞부분(원: Array<배치줄>) -> Array<배치줄> {
  if 원.length == 0 {
    []
  } else {
    원.slice(0, 원.length - 1)
  }
}

function 빈페이지() -> Array<배치줄> {
  let mut 비: Array<배치줄> = []
  비
}

// 한 줄을 SVG로 그린다. 양끝맞춤이면 남는 폭을 내부 경계에 고르게 나눈다(문단의 마지막 줄은 제외).
// Draw one line as SVG. When justified, spread the leftover width over interior boundaries (not the
// paragraph's last line).
function 줄그리기(조각들: Array<조각>, 줄정보: 줄, 목표: int, 마지막: bool, 단위: float, 세로: float) -> string {
  let 에이 = 줄정보.시작
  let 비 = 줄정보.끝
  let mut 폭합 = 0
  let mut 공백수 = 0
  let mut 칸 = 에이
  while 칸 <= 비 {
    폭합 = 폭합 + 조각들[칸].너비
    if 칸 > 에이 && 조각들[칸].앞공백 { 공백수 = 공백수 + 1 }
    칸 = 칸 + 1
  }
  let 자연폭 = 폭합 + 공백수
  let 경계수 = 비 - 에이
  let mut 늘림 = 0.0
  if !마지막 && 경계수 > 0 && 자연폭 < 목표 {
    늘림 = toFloat(목표 - 자연폭) / toFloat(경계수)
  }
  let mut 본문 = ""
  let mut 엑스 = 0.0
  let mut 케이 = 에이
  while 케이 <= 비 {
    if 케이 > 에이 {
      let mut 사이 = 0.0
      if 조각들[케이].앞공백 { 사이 = 1.0 }
      엑스 = 엑스 + (사이 + 늘림) * 단위
    }
    let 픽셀와이 = 세로
    본문 = 본문 + "<text x=\"{엑스}\" y=\"{픽셀와이}\" font-size=\"15\" font-family=\"monospace\" fill=\"#111\">{이스케이프(조각들[케이].텍스트)}</text>"
    엑스 = 엑스 + toFloat(조각들[케이].너비) * 단위
    케이 = 케이 + 1
  }
  본문
}

// 한 페이지를 SVG 한 상자로 그린다. 페이지 라벨과 양끝맞춤 목표를 보여주는 옅은 오른쪽 경계선을 넣는다.
// Draw one page as one SVG box, with a page label and a faint right margin line at the target width.
function 페이지그리기(페이지: Array<배치줄>, 문단결과들: Array<문단결과>, 목표: int, 단위: float, 줄높이: float, 페이지높이: int, 페이지번호: int) -> string {
  let 본문폭 = toFloat(목표) * 단위 + 2.0
  let 박스높이 = toFloat(페이지높이) * 줄높이 + 28.0
  let mut 본문 = ""
  본문 = 본문 + "<text x=\"2\" y=\"14\" font-size=\"11\" font-family=\"monospace\" fill=\"#888\">{페이지번호}페이지</text>"
  본문 = 본문 + "<line x1=\"{본문폭 - 2.0}\" y1=\"20\" x2=\"{본문폭 - 2.0}\" y2=\"{박스높이}\" stroke=\"#ddd\" />"
  let mut 줄번 = 0
  while 줄번 < 페이지.length {
    let 항 = 페이지[줄번]
    let 조각들 = 문단결과들[항.문단].조각들
    let 마지막 = 항.위치 == 항.문단줄수 - 1
    let 세로 = toFloat(줄번) * 줄높이 + 38.0
    본문 = 본문 + 줄그리기(조각들, { 시작: 항.시작, 끝: 항.끝 }, 목표, 마지막, 단위, 세로)
    줄번 = 줄번 + 1
  }
  "<svg viewBox=\"0 0 {본문폭} {박스높이}\" width=\"100%\" style=\"border:1px solid #bbb;margin-top:10px;display:block\">" + 본문 + "</svg>"
}

function 전체그림(페이지들: Array<Array<배치줄>>, 문단결과들: Array<문단결과>, 목표: int, 페이지높이: int) -> string {
  let 단위 = 9.0
  let 줄높이 = 24.0
  let mut 본문 = ""
  let mut 피 = 0
  while 피 < 페이지들.length {
    본문 = 본문 + 페이지그리기(페이지들[피], 문단결과들, 목표, 단위, 줄높이, 페이지높이, 피 + 1)
    피 = 피 + 1
  }
  본문
}

function 처리(입력: string) -> string {
  // 입력이 너무 길면 거른다(O(폭) 폭의 줄나눔이라도 글자 수가 크면 느려질 수 있다).
  // Reject very long input (line breaking stays bounded but huge text can still be slow).
  if 입력.scalars().length > 12000 {
    return "<div class=\"err\">입력이 너무 깁니다. 12000자 이하로 줄여주세요.</div>"
  }
  let 줄목록 = 입력.split("\n")
  let 첫줄 = 줄목록[0].trim()
  if 첫줄 == "" {
    return "<div class=\"meta\">첫 줄에 줄 너비와 선택 페이지 줄 수를 적고, 다음 줄부터 문단을 입력하세요. 문단은 빈 줄로 구분합니다.</div>"
  }
  let 토큰들 = filter(첫줄.split(" "), 토 => 토 != "")
  if 토큰들.length > 2 {
    return "<div class=\"err\">첫 줄은 줄 너비와 선택 페이지 줄 수, 최대 두 개입니다.</div>"
  }
  let 폭문자 = 토큰들[0]
  let mut 숫자만 = true
  for 글 in 폭문자.scalars() { if !숫자글(글) { 숫자만 = false } }
  if !숫자만 {
    return "<div class=\"err\">줄 너비는 양의 정수여야 합니다.</div>"
  }
  if 폭문자.scalars().length > 4 {
    return "<div class=\"err\">줄 너비가 너무 큽니다.</div>"
  }
  let 목표 = toInt(폭문자) ?? 0
  if 목표 < 1 {
    return "<div class=\"err\">줄 너비는 1 이상이어야 합니다.</div>"
  }
  let mut 페이지높이 = 6
  let mut 높이경고 = false
  if 토큰들.length >= 2 {
    let 높이문자 = 토큰들[1]
    let mut 높이숫자만 = true
    for 글 in 높이문자.scalars() { if !숫자글(글) { 높이숫자만 = false } }
    if !높이숫자만 {
      return "<div class=\"err\">페이지 줄 수는 양의 정수여야 합니다.</div>"
    }
    if 높이문자.scalars().length > 3 {
      return "<div class=\"err\">페이지 줄 수가 너무 큽니다.</div>"
    }
    페이지높이 = toInt(높이문자) ?? 6
    if 페이지높이 < 2 { 페이지높이 = 2; 높이경고 = true }
    if 페이지높이 > 30 { 페이지높이 = 30 }
  }
  // 문단 분리: 빈 줄로 나누고, 한 문단의 여러 줄은 공백으로 잇는다.
  // Split paragraphs on blank lines; join a paragraph's lines with a space.
  let mut 문단들: Array<string> = []
  let mut 현재 = ""
  let mut 줄인덱스 = 1
  while 줄인덱스 < 줄목록.length {
    let 줄 = 줄목록[줄인덱스]
    if 줄.trim() == "" {
      if 현재 != "" { 문단들.push(현재); 현재 = "" }
    } else {
      if 현재 != "" { 현재 = 현재 + " " }
      현재 = 현재 + 줄
    }
    줄인덱스 = 줄인덱스 + 1
  }
  if 현재 != "" { 문단들.push(현재) }
  if 문단들.length == 0 {
    return "<div class=\"meta\">문단을 입력하면 최적 줄나눔과 페이지 배치로 보여줍니다.</div>"
  }
  // 문단별 줄나눔.
  // Lay out each paragraph.
  let mut 문단결과들: Array<문단결과> = []
  let mut 금칙무시any = false
  let mut 피 = 0
  while 피 < 문단들.length {
    let 결과 = 문단처리(문단들[피], 목표)
    문단결과들.push(결과)
    if 결과.금칙무시 { 금칙무시any = true }
    피 = 피 + 1
  }
  // 평탄화: 모든 문단의 줄을 한 시퀀스로, 문단 정보(위치/문단줄수)와 함께 담는다.
  // Flatten every paragraph's lines into one sequence with paragraph metadata.
  let mut 시퀀스: Array<배치줄> = []
  let mut 피2 = 0
  while 피2 < 문단결과들.length {
    let 줄들 = 문단결과들[피2].줄들
    let mut 라 = 0
    while 라 < 줄들.length {
      시퀀스.push({ 문단: 피2, 시작: 줄들[라].시작, 끝: 줄들[라].끝, 위치: 라, 문단줄수: 줄들.length })
      라 = 라 + 1
    }
    피2 = 피2 + 1
  }
  if 시퀀스.length == 0 {
    return "<div class=\"meta\">문단을 입력하면 최적 줄나눔과 페이지 배치로 보여줍니다.</div>"
  }
  // 페이지네이션 + 과부/고아. 고아: 다줄 문단의 첫 줄을 페이지 맨 아래 한 칸에 두지 않는다.
  // 과부: 다줄 문단의 끝앞 줄을 두었더니 페이지가 꽉 차면, 그 줄을 다음 페이지로 내려 마지막 두 줄을 붙인다.
  // Pagination with widow/orphan. Orphan: keep a multi-line paragraph's first line off a page's last
  // slot. Widow: if placing the next-to-last line fills the page, move it down so the last two lines stay together.
  let mut 페이지들: Array<Array<배치줄>> = []
  let mut 현재페이지: Array<배치줄> = 빈페이지()
  let mut 고아막음 = 0
  let mut 과부막음 = 0
  let mut 엘 = 0
  while 엘 < 시퀀스.length {
    let 항 = 시퀀스[엘]
    let 첫줄여부 = 항.위치 == 0
    let 다줄 = 항.문단줄수 >= 2
    let 꽉참 = 현재페이지.length == 페이지높이
    let 고아조건 = 첫줄여부 && 다줄 && 현재페이지.length == 페이지높이 - 1
    let 이유 = 나눔이유정하기(꽉참, 고아조건)
    match 이유 {
      case "full" => {
        페이지들.push(현재페이지)
        현재페이지 = 빈페이지()
      }
      case "orphan" => {
        고아막음 = 고아막음 + 1
        페이지들.push(현재페이지)
        현재페이지 = 빈페이지()
      }
      case "none" => {}
    }
    현재페이지.push(항)
    let 끝앞 = 항.위치 == 항.문단줄수 - 2
    if 끝앞 && 다줄 && 현재페이지.length == 페이지높이 {
      현재페이지 = 앞부분(현재페이지)
      페이지들.push(현재페이지)
      let mut 새페이지: Array<배치줄> = 빈페이지()
      새페이지.push(항)
      현재페이지 = 새페이지
      과부막음 = 과부막음 + 1
    }
    엘 = 엘 + 1
  }
  if 현재페이지.length > 0 { 페이지들.push(현재페이지) }
  let mut 머리 = "<div class=\"meta\">{페이지들.length}페이지, 너비 {목표}칸, 페이지당 {페이지높이}줄. 고아 {고아막음}건과 과부 {과부막음}건을 방지했습니다"
  if 높이경고 { 머리 = 머리 + ". 페이지 줄 수는 최소 2이므로 2로 맞췄습니다" }
  if 금칙무시any { 머리 = 머리 + ". 폭이 좁아 일부 문단에서 금칙을 완화했습니다" }
  머리 = 머리 + "</div>"
  머리 + 전체그림(페이지들, 문단결과들, 목표, 페이지높이)
}

print(처리(input()))