С std.typeset абзац разбивается на строки заданной ширины, а для каждой строки считается, насколько растянуть или сжать пробелы. Абзацы, где корейский, китайский или японский текст смешан с латиницей, тоже разбиваются по правилам Юникода и по правилам кинсоку каждого языка. Правила кинсоку не дают закрывающей скобке или точке начинать строку, а открывающей скобке заканчивать её.
Модуль подключается командой import std.typeset в языковом режиме 5.21. Чтобы запускать примеры, установите Топаз по странице Первая программа. Другие стандартные модули описаны в разделе Обзор стандартной библиотеки.
Разбить абзац на строки
Сохраните код в файл typeset-paragraph.tpz. Ширина задаётся в миллипунктах, это тысячная доля пункта. Для шрифта 10pt знаки с кодом 4352 (U+1100) и выше имеют ширину 10 000, остальные знаки 5 000, пробел 2 500. Ширина строки 100 000, то есть 100pt.
import std.io
import std.typeset
function ширинаЗнака(знак: string) -> int {
if знак == " " { return 2500 }
match знак.codePointAt(0) {
case Some(код) => if код >= 4352 { 10000 } else { 5000 };
case None => 0
}
}
function разбить(текст: string, ширина: int) -> Result<Array<string>, typeset.TypesetError> {
let знаки = текст.scalars()
let кластеры = знаки.map(знак => typeset.BreakCluster { text: знак, advance: ширинаЗнака(знак), trackingAfter: 0 })
let места = typeset.classifyBreaks(кластеры, typeset.BreakPolicy {
mode: typeset.BreakMode.Word, rules: typeset.KinsokuRules.Unicode16Ko,
forbidBefore: [], forbidAfter: [], boundaryAllowed: знаки.map(знак => true),
generatedHyphenEnds: [], protectFittingLatin: false, preserveShortWords: false,
narrowestWordTarget: ширина, bodyWordTarget: ширина
})?
let mut элементы: Array<typeset.Item> = []
for номер in 0..<знаки.length {
элементы.push(typeset.Item.BoxItem(typeset.Box { unitIndex: номер, text: знаки[номер], width: ширинаЗнака(знаки[номер]), trackingAfter: 0 }))
}
for место in места {
let правило = if место.kind == typeset.BreakKind.ForcedEnd { typeset.BreakRule.Forced } else { typeset.BreakRule.Allowed(0) }
элементы.push(typeset.Item.PenaltyItem(typeset.Penalty { unitEnd: место.unitEnd, width: 0, rule: правило, kind: место.kind, flagged: false }))
}
let результат = typeset.breakParagraph(typeset.Paragraph {
grid: typeset.DistanceGrid.MilliPoint,
items: элементы,
geometry: typeset.LineGeometry { bodyTarget: ширина, firstTarget: ширина, dropCapSpan: 0, dropCapInset: 0 },
justification: typeset.JustificationPolicy {
mode: typeset.JustificationMode.Unjustified, minGapCount: 0, minLineWidthRatioM: 0,
minFinalLineWidthRatioM: None, maxAdjustment: 0, maxGapAdjustment: 0, maxGapRatioR: 0,
latinSpacing: None, cjkStretchRatio: 0.0
},
costModel: typeset.CostModel.FixedPoint,
knuthPlass: typeset.KnuthPlassPolicy { toleranceR: 200000000, linePenalty: 10, fitnessDemerit: 10000, flaggedDemerit: 10000, finalFlaggedDemerit: 5000 }
})?
Ok(результат.optimized.lines.map(строка => знаки.slice(строка.start, строка.trimmedEndExclusive).join("")))
}
let текст = "토파즈는 책과 문서를 조판하는 언어입니다. 한국어와 English가 섞인 문단도 규칙대로 나눕니다."
match разбить(текст, 100000) {
case Ok(строки) => { for строка in строки { io.writeLine(строка) } };
case Err(ошибка) => io.writeLine("{ошибка}")
}Запустите topaz run typeset-paragraph.tpz в папке с этим файлом.
토파즈는 책과 문서를
조판하는 언어입니다.
한국어와 English가
섞인 문단도
규칙대로 나눕니다.Программа разбивает текст за три шага.
classifyBreaksпроверяет каждый кластер (BreakCluster) и решает, можно ли после него перенести строку. Результат содержит список мест разрыва (BreakOpportunity).- Программа добавляет в абзац (
Paragraph) бокс (Box) для каждого знака и штраф (Penalty) для каждого места разрыва. В конце абзаца стоит обязательный разрывForcedEnd, поэтому он превращается вBreakRule.Forced. breakParagraphстроит план строк. Каждая строка (PlanLine) показывает единицы отstartдоtrimmedEndExclusive, не включая последнюю позицию.
BreakMode.Word не делит корейское слово, если оно помещается в строку. Поэтому строки меняются только на границах слов. breakParagraph возвращает сразу два плана, оптимальный optimized и жадный greedy. Их разница показана ниже, в разделе о жадном и оптимальном разбиении.
Длины и единицы
Размеры в вёрстке записываются длинами вроде 12pt, 3mm, 1cm, 1in и 1em. Единица входит в тип. 148mm имеет тип Length<Mm>, а 10pt имеет тип Length<Pt>. Складывать и вычитать можно только длины с одной единицей. Выражение 90mm + 10pt проверка отклоняет с кодом TPZ5001, поэтому сначала преобразуйте единицы.
import std.io
import std.typeset
let страница = 148mm
let поле = 20mm
let колонка = страница - поле * 2
let кегль = 10pt
io.writeLine("{колонка}")
io.writeLine("{колонка.toPt().toMilliPoints()}")
io.writeLine("{1em.resolve(кегль).toMilliPoints()}")
io.writeLine("{typeset.fromMilliPoints(10000) == кегль}")Length<Mm>(20063304)
306142
10000
true148mm - 20mm * 2даёт значениеLength<Mm>. ВыводLength<Mm>(20063304)показывает хранимое значение в масштабированных пунктах. В одном пункте 65 536 масштабированных пунктов.toPt()переводит длину в пункты, аtoMilliPoints()возвращает ближайшее целое число миллипунктов. 108mm равны 306 142 миллипунктам.1emзадаёт длину относительно кегля.resolveпереводит её в пункты для заданного кегля.typeset.fromMilliPointsиtypeset.fromScaledPointsпревращают целое число обратно вLength<Pt>.
Ширины в API абзаца хранятся целыми числами. Поле Paragraph.grid называет их единицу. DistanceGrid.MilliPoint означает миллипункты, DistanceGrid.ScaledPoint означает масштабированные пункты. CostModel.FixedPoint принимает только MilliPoint.
Измерить ширину знаков
std.typeset не открывает шрифты и не читает файлы. Программа сама измеряет знаки по шрифту и передаёт ширину в BreakCluster.advance и Box.width. Если нужен трекинг, включите в ширину интервал после знака и отдельно запишите его в trackingAfter.
import std.io
import std.typeset
let элементы: Array<typeset.Item> = [
typeset.Item.BoxItem(typeset.Box { unitIndex: 0, text: "가", width: 10200, trackingAfter: 200 }),
typeset.Item.BoxItem(typeset.Box { unitIndex: 1, text: "나", width: 10200, trackingAfter: 200 }),
typeset.Item.BoxItem(typeset.Box { unitIndex: 2, text: " ", width: 2700, trackingAfter: 200 }),
typeset.Item.PenaltyItem(typeset.Penalty { unitEnd: 3, width: 0, rule: typeset.BreakRule.Forced, kind: typeset.BreakKind.ForcedEnd, flagged: false })
]
let абзац = typeset.Paragraph {
grid: typeset.DistanceGrid.MilliPoint,
items: элементы,
geometry: typeset.LineGeometry { bodyTarget: 100000, firstTarget: 100000, dropCapSpan: 0, dropCapInset: 0 },
justification: typeset.JustificationPolicy {
mode: typeset.JustificationMode.Unjustified, minGapCount: 0, minLineWidthRatioM: 0,
minFinalLineWidthRatioM: None, maxAdjustment: 0, maxGapAdjustment: 0, maxGapRatioR: 0,
latinSpacing: None, cjkStretchRatio: 0.0
},
costModel: typeset.CostModel.FixedPoint,
knuthPlass: typeset.KnuthPlassPolicy { toleranceR: 200000000, linePenalty: 10, fitnessDemerit: 10000, flaggedDemerit: 10000, finalFlaggedDemerit: 5000 }
}
match typeset.breakParagraph(абзац) {
case Ok(результат) => {
let строка = результат.optimized.lines[0]
io.writeLine("raw {строка.endExclusive} {строка.rawTextWidth}")
io.writeLine("trimmed {строка.trimmedEndExclusive} {строка.trimmedTextWidth}")
io.writeLine("scalars {строка.displayTextScalarLength}")
};
case Err(ошибка) => io.writeLine("{ошибка}")
}raw 3 22900
trimmed 2 20200
scalars 2Из ширины строки один раз вычитается трекинг после последней единицы. rawTextWidth учитывает конечный пробел и равна 10 200 + 10 200 + 2 700 − 200 = 22 900. trimmedTextWidth не учитывает конечный пробел и трекинг после 나, поэтому равна 20 200. trimmedEndExclusive указывает на позицию 2 перед пробелом. displayTextScalarLength считает 2 скаляра, которые остаются на экране. Конечными пробелами считаются U+0020 и неразрывный пробел U+00A0.
Места разрыва (UAX #14)
UAX #14 описывает алгоритм разрыва строк в Юникоде. Он присваивает каждому знаку класс разрыва и решает, можно ли перенести строку между двумя знаками. classifyBreaks применяет данные Юникода 16.0.0 и правила с LB1 по LB31 из UAX #14 редакции 53, а затем добавляет кинсоку выбранной локали. Следующий пример ставит | в каждом месте разрыва.
import std.io
import std.typeset
function разметить(текст: string, правила: typeset.KinsokuRules, режим: typeset.BreakMode) -> string {
let знаки = текст.scalars()
let кластеры = знаки.map(знак => typeset.BreakCluster { text: знак, advance: 10000, trackingAfter: 0 })
let политика = typeset.BreakPolicy {
mode: режим, rules: правила, forbidBefore: [], forbidAfter: [],
boundaryAllowed: знаки.map(знак => true), generatedHyphenEnds: [],
protectFittingLatin: false, preserveShortWords: false,
narrowestWordTarget: 100000, bodyWordTarget: 100000
}
match typeset.classifyBreaks(кластеры, политика) {
case Ok(места) => {
let концы = места.map(место => место.unitEnd)
let mut вывод = ""
for номер in 0..<знаки.length {
вывод = "{вывод}{знаки[номер]}"
if номер + 1 < знаки.length && концы.indexOf(номер + 1) != None { вывод = "{вывод}|" }
}
вывод
};
case Err(ошибка) => "{ошибка}"
}
}
let корейский = typeset.KinsokuRules.Unicode16Ko
for текст in ["Hello, world! It's 3.14.", "e.g. well-known", "equals .35 cents", "조판은 (보기처럼) 글자를 나눕니다."] {
io.writeLine(разметить(текст, корейский, typeset.BreakMode.Character))
}
io.writeLine(разметить("조판은 (보기처럼) 글자를 나눕니다.", корейский, typeset.BreakMode.Word))Hello, |world! |It's |3.14.
e.g. |well-|known
equals .35 |cents
조|판|은 |(보|기|처|럼) |글|자|를 |나|눕|니|다.
조판은 |(보기처럼) |글자를 |나눕니다.- Строку можно перенести после пробела и после дефиса. Внутри числа вроде
3.14, перед закрывающими знаками и после открывающей скобки разрыва нет. - По одному UAX #14 разрыв допустим перед
.35.Unicode16Koне начинает строку с точки и убирает это место. BreakMode.Characterразрешает разрыв между любыми слогами хангыля.BreakMode.Wordне делит корейское слово, если его ширина не большеbodyWordTarget.
У каждого места разрыва есть вид (BreakKind). После пробела стоит LatinSpace, после дефиса LatinHyphen, после закрывающего знака ClosingPunctuation, после восточноазиатского знака KoreanCluster. Конец абзаца и место после знака перевода строки дают обязательный разрыв ForcedEnd. Все остальные места получают вид HardFallback. Модель стоимости FixedPoint добавляет строке, разорванной в таком месте, 50 000B, где B равно 1 000 000. Модель KnuthPlass вид разрыва в стоимости не учитывает.
Восточноазиатские знаки и классы JLREQ
Хангыль, иероглифы и кана относятся к широким восточноазиатским знакам. Между ними строку можно перенести даже без пробела. classifyBreaks считает восточноазиатскими знаки, у которых свойство Юникода East_Asian_Width равно F (полноширинный), W (широкий) или H (полуширинный).
Документ W3C JLREQ описывает требования к японской вёрстке. Он делит знаки препинания и кану на классы, например открывающие скобки, закрывающие скобки и малую кану. Для каждого класса указано, может ли знак начинать или заканчивать строку. Unicode16Ja применяет строгие кинсоку из разделов JLREQ с 3.1.7 по 3.1.10 и таблицы классов из приложения A.
import std.io
import std.typeset
function разметить(текст: string, правила: typeset.KinsokuRules, режим: typeset.BreakMode) -> string {
let знаки = текст.scalars()
let кластеры = знаки.map(знак => typeset.BreakCluster { text: знак, advance: 10000, trackingAfter: 0 })
let политика = typeset.BreakPolicy {
mode: режим, rules: правила, forbidBefore: [], forbidAfter: [],
boundaryAllowed: знаки.map(знак => true), generatedHyphenEnds: [],
protectFittingLatin: false, preserveShortWords: false,
narrowestWordTarget: 100000, bodyWordTarget: 100000
}
match typeset.classifyBreaks(кластеры, политика) {
case Ok(места) => {
let концы = места.map(место => место.unitEnd)
let mut вывод = ""
for номер in 0..<знаки.length {
вывод = "{вывод}{знаки[номер]}"
if номер + 1 < знаки.length && концы.indexOf(номер + 1) != None { вывод = "{вывод}|" }
}
вывод
};
case Err(ошибка) => "{ошибка}"
}
}
let познаково = typeset.BreakMode.Character
io.writeLine("ko {разметить("가나", typeset.KinsokuRules.Unicode16Ko, познаково)}")
io.writeLine("zh {разметить("甲,乙", typeset.KinsokuRules.Unicode16Zh, познаково)}")
io.writeLine("zh {разметить("甲(乙)", typeset.KinsokuRules.Unicode16Zh, познаково)}")
for текст in ["カァナ", "カーキ", "あゝい"] {
io.writeLine("ja {разметить(текст, typeset.KinsokuRules.Unicode16Ja, познаково)}")
}ko 가|나
zh 甲,|乙
zh 甲|(乙)
ja カァ|ナ
ja カー|キ
ja あゝ|い- Между двумя слогами хангыля строку перенести можно, как в
가|나. - Запятая
,и закрывающая скобка)не начинают строку. Открывающая скобка(её не заканчивает. - Малая кана
ァ, знак долготыーи знак повтораゝне начинают строку.
Жадное и оптимальное разбиение
Жадное разбиение заполняет строки по одной. Оно кладёт в текущую строку всё, что помещается, и переходит к следующей, не глядя вперёд. Оптимальное разбиение сравнивает сочетания мест разрыва во всём абзаце и выбирает план с наименьшей суммарной стоимостью.
import std.io
import std.typeset
function ширинаЗнака(знак: string) -> int {
if знак == " " { return 2500 }
match знак.codePointAt(0) {
case Some(код) => if код >= 4352 { 10000 } else { 5000 };
case None => 0
}
}
function план(знаки: Array<string>, ширина: int) -> Result<typeset.ParagraphResult, typeset.TypesetError> {
let кластеры = знаки.map(знак => typeset.BreakCluster { text: знак, advance: ширинаЗнака(знак), trackingAfter: 0 })
let места = typeset.classifyBreaks(кластеры, typeset.BreakPolicy {
mode: typeset.BreakMode.Word, rules: typeset.KinsokuRules.Unicode16Ko,
forbidBefore: [], forbidAfter: [], boundaryAllowed: знаки.map(знак => true),
generatedHyphenEnds: [], protectFittingLatin: false, preserveShortWords: false,
narrowestWordTarget: ширина, bodyWordTarget: ширина
})?
let mut элементы: Array<typeset.Item> = []
for номер in 0..<знаки.length {
элементы.push(typeset.Item.BoxItem(typeset.Box { unitIndex: номер, text: знаки[номер], width: ширинаЗнака(знаки[номер]), trackingAfter: 0 }))
}
for место in места {
let правило = if место.kind == typeset.BreakKind.ForcedEnd { typeset.BreakRule.Forced } else { typeset.BreakRule.Allowed(0) }
элементы.push(typeset.Item.PenaltyItem(typeset.Penalty { unitEnd: место.unitEnd, width: 0, rule: правило, kind: место.kind, flagged: false }))
}
typeset.breakParagraph(typeset.Paragraph {
grid: typeset.DistanceGrid.MilliPoint,
items: элементы,
geometry: typeset.LineGeometry { bodyTarget: ширина, firstTarget: ширина, dropCapSpan: 0, dropCapInset: 0 },
justification: typeset.JustificationPolicy {
mode: typeset.JustificationMode.Unjustified, minGapCount: 0, minLineWidthRatioM: 0,
minFinalLineWidthRatioM: None, maxAdjustment: 0, maxGapAdjustment: 0, maxGapRatioR: 0,
latinSpacing: None, cjkStretchRatio: 0.0
},
costModel: typeset.CostModel.FixedPoint,
knuthPlass: typeset.KnuthPlassPolicy { toleranceR: 200000000, linePenalty: 10, fitnessDemerit: 10000, flaggedDemerit: 10000, finalFlaggedDemerit: 5000 }
})
}
function показать(имя: string, знаки: Array<string>, план: typeset.ParagraphPlan) {
io.writeLine("{имя} {план.totalCost}")
for строка in план.lines {
io.writeLine(" {знаки.slice(строка.start, строка.trimmedEndExclusive).join("")} {строка.trimmedTextWidth}")
}
}
let знаки = "줄을 하나씩 채우면 마지막 줄이 짧게 남습니다".scalars()
match план(знаки, 100000) {
case Ok(результат) => {
показать("greedy", знаки, результат.greedy)
показать("optimized", знаки, результат.optimized)
};
case Err(ошибка) => io.writeLine("{ошибка}")
}greedy 685996370
줄을 하나씩 채우면 85000
마지막 줄이 짧게 75000
남습니다 40000
optimized 115677384
줄을 하나씩 52500
채우면 마지막 줄이 85000
짧게 남습니다 62500Жадный план заполняет первые две строки и оставляет на последней одно слово 남습니다. Последняя строка короче 52% ширины стоит намного дороже. Поэтому оптимальный план укорачивает первые строки и выравнивает длину всех трёх. Числа показывают суммарную стоимость каждого плана и ширину каждой строки без конечных пробелов.
breakParagraph возвращает оба плана. Нужный план берите из полей optimized или greedy в ParagraphResult. Способ расчёта стоимости задаётся полем Paragraph.costModel.
| Модель стоимости | Вход | Когда выбирать |
|---|---|---|
CostModel.FixedPoint | Достаточно одних боксов. Сетка только MilliPoint. | Корейские, китайские и японские абзацы без клея между знаками, а также абзацы, где интервалы между словами оцениваются через latinSpacing |
CostModel.KnuthPlass | Строке, у которой естественная ширина вместе с width выбранного штрафа равна цели, клей не нужен. Более короткой строке нужен клей с растяжением, а более длинной нужен клей со сжатием. | Абзацы с выключкой по ширине за счёт пробелов между словами |
Алгоритм разбиения Knuth-Plass создали Дональд Кнут и Майкл Пласс. Он оценивает каждую строку по тому, насколько нужно растянуть или сжать её клей. Эту оценку называют плохостью (badness). Затем алгоритм выбирает сочетание с наименьшей стоимостью для всего абзаца. Модель KnuthPlass рассматривает как кандидатов все предыдущие места разрыва. Модель FixedPoint считает стоимость в целых миллипунктах. Её оптимальный план не ставит мягкий перенос в конце трёх строк подряд. При равной стоимости обе модели выбирают кандидата, добавленного раньше.
Если жадный план построить нельзя, breakParagraph возвращает NoGreedyPlan, даже когда оптимальный план существует. С моделью KnuthPlass так бывает при низком toleranceR. Увеличьте toleranceR или растяжимость клея stretch.
Стоимость
Стоимость плана задаётся целым числом. Чем она меньше, тем лучше план. В модели FixedPoint стоимость строки равна значению lineBadnessMpt, а если выбранный штраф равен Allowed(n), к ней прибавляется n как есть. Стоимость одной строки можно посчитать прямо через lineBadnessMpt.
import std.io
import std.typeset
let цель = 100000
let пробел = typeset.BreakKind.LatinSpace
io.writeLine("fit {typeset.lineBadnessMpt(100000, цель, 10, 2, пробел, false, 0, 0)}")
io.writeLine("short {typeset.lineBadnessMpt(90000, цель, 9, 2, пробел, false, 0, 0)}")
io.writeLine("hyphen {typeset.lineBadnessMpt(100000, цель, 10, 2, typeset.BreakKind.LatinHyphen, false, 0, 0)}")
io.writeLine("last {typeset.lineBadnessMpt(40000, цель, 4, 0, typeset.BreakKind.ForcedEnd, true, 0, 0)}")
io.writeLine("zero {typeset.lineBadnessMpt(100000, 0, 10, 2, пробел, false, 0, 0)}")fit Ok(10000000)
short Ok(12770080)
hyphen Ok(75000000)
last Ok(654000000)
zero Err(TypesetError.InvalidInput(target must be positive; counts must be nonnegative))В таблице ниже собраны составляющие стоимости модели FixedPoint и lineBadnessMpt, B равно 1 000 000. Строка точно по ширине стоит 10B. Строка, которая кончается дефисом, получает ещё 65B, всего 75B. Последняя строка, заполненная на 40%, получает ещё 500B. Нулевая целевая ширина возвращает InvalidInput.
| Условие | Добавочная стоимость |
|---|---|
| Любая строка | 10B |
| Разница между шириной строки и целевой шириной | Растёт с квадратом доли разницы. Чем больше пробелов между словами, тем она меньше. |
| Строка шире цели | 20 000B и больше |
| Строка, которая кончается дефисом или мягким переносом | 65B |
Разрыв в месте HardFallback | 50 000B |
| Строка из двух знаков или меньше | 275B |
| Последняя строка | Стоимость разницы уменьшается до двух пятых. Если строка короче 52% цели, добавляется 500B. |
Аргумент justificationB | Добавляется как есть |
Модель KnuthPlass вычисляет для каждой строки коэффициент r. Остаток ширины равен цели минус естественная ширина строки и width выбранного штрафа. r равно остатку, делённому на суммарное растяжение, если остаток не меньше 0, и на суммарное сжатие, если он отрицателен. Строки с r меньше −1 или больше toleranceR / 100 000 000 отбрасываются. Если растяжение или сжатие, на которое делится остаток, равно 0, строка подходит только при нулевом остатке. Плохость равна округлённому 100 × |r|³ и не превышает 10 000. Стоимость строки равна (linePenalty + плохость)², к которой добавляется следующее.
- Для штрафа
Allowed(n)прибавляется n², если n не меньше 0, и вычитается n², если n отрицательно. fitnessDemeritприбавляется, когда классы плотности двух соседних строк отличаются на два или больше. Всего классов четыре. Их границы по r равны −0,5, 0,5 и 1.flaggedDemeritприбавляется, когда две строки подряд кончаются на штрафах сflagged.finalFlaggedDemeritприбавляется, когда предыдущая строка кончилась на штрафе сflagged, а эта строка последняя в абзаце.
Переполнение целого числа не возвращает TypesetError. Программа останавливается с ошибкой выполнения TPZ4004.
Выровнять интервалы
Выключка по ширине распределяет остаток ширины строки по её пробелам, чтобы строка заканчивалась ровно у края. В модели KnuthPlass пробелы добавляются как клей (Glue). У клея есть естественная ширина width, растяжение stretch и сжатие shrink. Следующий пример рассчитан на шрифт 10pt. В нём пробел равен 3 333 миллипунктам, растяжение 1 667, сжатие 1 111.
import std.io
import std.typeset
let пробел = 3333
let растяжение = 1667
let сжатие = 1111
function план(знаки: Array<string>, ширина: int) -> Result<typeset.ParagraphResult, typeset.TypesetError> {
let mut элементы: Array<typeset.Item> = []
for номер in 0..<знаки.length {
if знаки[номер] == " " {
элементы.push(typeset.Item.GlueItem(typeset.Glue { unitIndex: номер, text: " ", width: пробел, trackingAfter: 0, stretch: растяжение, shrink: сжатие }))
элементы.push(typeset.Item.PenaltyItem(typeset.Penalty { unitEnd: номер + 1, width: 0, rule: typeset.BreakRule.Allowed(0), kind: typeset.BreakKind.LatinSpace, flagged: false }))
} else {
элементы.push(typeset.Item.BoxItem(typeset.Box { unitIndex: номер, text: знаки[номер], width: 5000, trackingAfter: 0 }))
}
}
let конец = знаки.length
элементы.push(typeset.Item.GlueItem(typeset.Glue { unitIndex: конец, text: "", width: 0, trackingAfter: 0, stretch: 100000000, shrink: 0 }))
элементы.push(typeset.Item.PenaltyItem(typeset.Penalty { unitEnd: конец + 1, width: 0, rule: typeset.BreakRule.Forced, kind: typeset.BreakKind.ForcedEnd, flagged: false }))
typeset.breakParagraph(typeset.Paragraph {
grid: typeset.DistanceGrid.MilliPoint,
items: элементы,
geometry: typeset.LineGeometry { bodyTarget: ширина, firstTarget: ширина, dropCapSpan: 0, dropCapInset: 0 },
justification: typeset.JustificationPolicy {
mode: typeset.JustificationMode.Unjustified, minGapCount: 0, minLineWidthRatioM: 0,
minFinalLineWidthRatioM: None, maxAdjustment: 0, maxGapAdjustment: 0, maxGapRatioR: 0,
latinSpacing: None, cjkStretchRatio: 0.0
},
costModel: typeset.CostModel.KnuthPlass,
knuthPlass: typeset.KnuthPlassPolicy { toleranceR: 200000000, linePenalty: 10, fitnessDemerit: 10000, flaggedDemerit: 10000, finalFlaggedDemerit: 5000 }
})
}
let знаки = "Typesetting splits a paragraph into lines and then spreads the spaces in each line evenly.".scalars()
match план(знаки, 150000) {
case Ok(результат) => {
for строка in результат.optimized.lines {
let коэф = строка.adjustmentRatioR
let интервал = if коэф >= 0 { пробел + коэф * растяжение / 100000000 } else { пробел + коэф * сжатие / 100000000 }
io.writeLine("{знаки.slice(строка.start, строка.trimmedEndExclusive).join("")} | {коэф} {интервал}")
}
};
case Err(ошибка) => io.writeLine("{ошибка}")
}Typesetting splits a paragraph | 100000000 5000
into lines and then spreads the | 40011998 4000
spaces in each line evenly. | 21667 3333adjustmentRatioR хранит коэффициент r, умноженный на 100 000 000. Если r не меньше 0, ширина пробела равна width + r × stretch. Если r отрицателен, она равна width + r × shrink. В первой строке r равен 1, и пробелы растут с 3 333 до 5 000. Во второй строке r около 0,40, и пробелы равны 4 000.
В конце последней строки стоят клей с очень большим растяжением и штраф Forced. Так строка остаётся выровненной влево. Штраф Forced оставляет в строке клей прямо перед собой, поэтому r последней строки близок к 0. В обычном месте разрыва клей в конце строки отбрасывается, а клей в начале следующей строки пропускается.
Модель FixedPoint оценивает выключку через JustificationPolicy, а не через клей. С latinSpacing она считает, что пробелы между словами сжимаются до minWordSpaceRatio и растягиваются до maxWordSpaceRatio от естественной ширины. Буквы могут расходиться не больше чем на maxLetterSpacingEm. Допустимые диапазоны по порядку от 0,8 до 1,0, от 1,0 до 1,33 и от 0 до 0,015. Значения вне диапазона возвращают InvalidPolicy. Если режим не Unjustified и latinSpacing задан, оптимальный план сначала ищется так, чтобы все строки, кроме последней, были заполнены в пределах, которые допускает политика выключки. Последняя строка не проверяется. Если такой план есть, он и используется, а optimized.boundedFillFeasible равно Some(true). Иначе поиск повторяется без этого условия, и поле равно Some(false). В остальных случаях оно равно None. У жадного плана boundedFillFeasible всегда None.
Локали
Правила кинсоку для языка выбираются полем BreakPolicy.rules. Три набора на основе Юникода сохраняют неизменяемые решения UAX #14 и добавляют поверх них кинсоку языка.
| Значение | Правила |
|---|---|
Unicode16Ko | KLREQ, требования W3C к вёрстке хангыля, разделы с 7.1.1 по 7.1.4. В режиме Word сохраняет корейские слова, которые помещаются в строку. |
Unicode16Zh | CLREQ, требования W3C к китайской вёрстке. Базовые кинсоку из 6.1.1 |
Unicode16Ja | Строгие кинсоку JLREQ из разделов с 3.1.7 по 3.1.10, знаки до и после чисел, пробелы между группами цифр |
AtlasDefault | Набор правил, который применял ATLAS. Вместо таблиц Юникода использует фиксированные списки знаков. |
Следующий пример разбивает один и тот же текст по Unicode16Ko, Unicode16Zh и Unicode16Ja.
import std.io
import std.typeset
function разметить(текст: string, правила: typeset.KinsokuRules, режим: typeset.BreakMode) -> string {
let знаки = текст.scalars()
let кластеры = знаки.map(знак => typeset.BreakCluster { text: знак, advance: 10000, trackingAfter: 0 })
let политика = typeset.BreakPolicy {
mode: режим, rules: правила, forbidBefore: [], forbidAfter: [],
boundaryAllowed: знаки.map(знак => true), generatedHyphenEnds: [],
protectFittingLatin: false, preserveShortWords: false,
narrowestWordTarget: 100000, bodyWordTarget: 100000
}
match typeset.classifyBreaks(кластеры, политика) {
case Ok(места) => {
let концы = места.map(место => место.unitEnd)
let mut вывод = ""
for номер in 0..<знаки.length {
вывод = "{вывод}{знаки[номер]}"
if номер + 1 < знаки.length && концы.indexOf(номер + 1) != None { вывод = "{вывод}|" }
}
вывод
};
case Err(ошибка) => "{ошибка}"
}
}
let правила = [typeset.KinsokuRules.Unicode16Ko, typeset.KinsokuRules.Unicode16Zh, typeset.KinsokuRules.Unicode16Ja]
for текст in ["1 000", "#一", "1㎏"] {
io.writeLine(правила.map(набор => разметить(текст, набор, typeset.BreakMode.Character)).join(" "))
}1 |000 1 |000 1 000
#|一 #|一 #一
1|㎏ 1|㎏ 1㎏Unicode16Ja не отделяет от числа знак # перед ним и единицу ㎏ после него. Пробел в 1 000 считается разделителем групп цифр, и разрыва там нет. Unicode16Ko и Unicode16Zh этих японских правил не применяют.
Обработать ошибки
classifyBreaks, breakParagraph и lineBadnessMpt возвращают Result<_, TypesetError>. Входные значения не меняются, а массивы результата создаются заново.
| Ошибка | Когда возвращается |
|---|---|
InvalidInput(message) | Список кластеров пуст или длина boundaryAllowed не совпадает с ним. Кластер содержит обязательный разрыв или boundaryAllowed его запрещает. unitIndex не идёт подряд от 0. У клея отрицательное растяжение или сжатие. Штраф указывает за пределы абзаца. Целевая ширина для lineBadnessMpt не больше 0. |
InvalidPolicy(message) | Целевая ширина в LineGeometry не больше 0 или dropCapSpan вне диапазона от 0 до 3. FixedPoint получает сетку, отличную от MilliPoint. Значение выключки, интервалов или Knuth-Plass вне допустимого диапазона. В forbidBefore или forbidAfter есть значение, которое не является скаляром Юникода. |
NoBreakOpportunities | Нет ни одного места разрыва, которое можно выбрать. |
NoGreedyPlan | Жадный план не удаётся построить до конца абзаца. |
NoPlan | Оптимальный план не находит разбиения до конца абзаца. Оптимальный план FixedPoint отбрасывает строки шире postValidationWidthLimit, поэтому при нехватке мест разрыва возникает эта ошибка. |
Открытый API
После import std.typeset имена пишутся как typeset.breakParagraph и typeset.BreakKind.ForcedEnd. Имена переменных и функций в примерах даны по-русски, а имена из модуля и поля записей остаются как есть.
Функции
| Функция | Сигнатура | Описание |
|---|---|---|
breakParagraph | (input: Paragraph) -> Result<ParagraphResult, TypesetError> | Строит оптимальный и жадный планы абзаца. |
classifyBreaks | (clusters: Array<BreakCluster>, policy: BreakPolicy) -> Result<Array<BreakOpportunity>, TypesetError> | Находит места разрыва и их вид на границах кластеров. |
lineBadnessMpt | (widthMpt: int, targetMpt: int, charCount: int, spaces: int, kind: BreakKind, isLast: bool, justificationB: int, hyphenAdvanceMpt: int) -> Result<int, TypesetError> | Считает стоимость строки в модели FixedPoint. |
fromMilliPoints | (value: int) -> Length<Pt> | Превращает целое число миллипунктов в длину. |
fromScaledPoints | (value: int) -> Length<Pt> | Берёт целое число масштабированных пунктов как длину без изменений. |
Входные типы
| Тип | Поля | Описание |
|---|---|---|
BreakCluster | text: string, advance: int, trackingAfter: int | Кластер для проверки и его ширина |
BreakPolicy | mode: BreakMode, rules: KinsokuRules, forbidBefore: Array<int>, forbidAfter: Array<int>, boundaryAllowed: Array<bool>, generatedHyphenEnds: Array<int>, protectFittingLatin: bool, preserveShortWords: bool, narrowestWordTarget: int, bodyWordTarget: int | Правила поиска мест разрыва. forbidBefore и forbidAfter перечисляют скаляры Юникода, перед которыми или после которых разрыва нет. boundaryAllowed содержит по одному значению на кластер и разрешает или запрещает разрыв после него. generatedHyphenEnds перечисляет границы внутри слов, где можно поставить мягкий перенос. Режим Word убирает места HardFallback внутри слова, ширина которого не больше bodyWordTarget. С правилами Unicode16Ko он так же убирает места между знаками хангыля внутри корейского слова такой ширины. Слово шире bodyWordTarget получает места HardFallback между буквами. |
Box | unitIndex: int, text: string, width: int, trackingAfter: int | Единица текста, которая не растягивается и не сжимается |
Glue | unitIndex: int, text: string, width: int, trackingAfter: int, stretch: int, shrink: int | Пробел, который может растягиваться и сжиматься |
Penalty | unitEnd: int, width: int, rule: BreakRule, kind: BreakKind, flagged: bool | Место разрыва на границе после первых unitEnd единиц. Единицу не занимает. width добавляется к ширине строки, разорванной здесь. KnuthPlass добавляет его всегда, а FixedPoint только когда kind равен LatinSoftHyphen. Добавленное значение хранится в PlanLine.selectedPenaltyWidth. flagged отмечает места вроде дефиса, которые не стоит повторять в соседних строках. В стоимости его учитывает только KnuthPlass через flaggedDemerit и finalFlaggedDemerit. |
Item | BoxItem(Box), GlueItem(Glue), PenaltyItem(Penalty) | Элемент абзаца |
LineGeometry | bodyTarget: int, firstTarget: int, dropCapSpan: int, dropCapInset: int | Целевая ширина строк. firstTarget действует для первой строки, bodyTarget для остальных. Первые dropCapSpan строк (от 0 до 3) вычитают из цели dropCapInset. |
LatinSpacing | minWordSpaceRatio: float, maxWordSpaceRatio: float, maxLetterSpacingEm: float, fontSizePt: float | Допустимые пределы для пробелов между латинскими словами и трекинга |
JustificationPolicy | mode: JustificationMode, minGapCount: int, minLineWidthRatioM: int, minFinalLineWidthRatioM: Option<int>, maxAdjustment: int, maxGapAdjustment: int, maxGapRatioR: int, latinSpacing: Option<LatinSpacing>, cjkStretchRatio: float | Правила выключки для модели FixedPoint. Модель KnuthPlass только проверяет эти значения. |
KnuthPlassPolicy | toleranceR: int, linePenalty: int, fitnessDemerit: int, flaggedDemerit: int, finalFlaggedDemerit: int | Допуск (× 100 000 000) и слагаемые стоимости модели KnuthPlass |
Paragraph | grid: DistanceGrid, items: Array<Item>, geometry: LineGeometry, justification: JustificationPolicy, costModel: CostModel, knuthPlass: KnuthPlassPolicy | Абзац для breakParagraph |
Типы результата
| Тип | Поля | Описание |
|---|---|---|
BreakOpportunity | unitEnd: int, kind: BreakKind | Место разрыва на границе после первых unitEnd кластеров |
PlanLine | start: int, endExclusive: int, trimmedEndExclusive: int, displayTextScalarLength: int, kind: BreakKind, target: int, rawTextWidth: int, trimmedTextWidth: int, costTextWidth: int, selectedPenaltyWidth: int, postValidationWidthLimit: Option<int>, cost: int, adjustmentRatioR: int | Одна строка. costTextWidth хранит ширину, по которой считалась стоимость. postValidationWidthLimit хранит предел ширины, по которому FixedPoint проверяет строку. Для последней строки это target + 1, для остальных target плюс сжатие по latinSpacing (0, если его нет) плюс 1. Оптимальный план отбрасывает строку, если её ширина без конечных пробелов больше этого значения. При мягком переносе в эту ширину входит и width штрафа. Строку из одной единицы он не отбрасывает. Жадный план только записывает это значение и не проверяет его. Поэтому строка из одной единицы и строка жадного плана могут быть шире этого значения. Для KnuthPlass значение равно None. В FixedPoint значение adjustmentRatioR равно 0. |
ParagraphPlan | lines: Array<PlanLine>, totalCost: int, boundedFillFeasible: Option<bool>, grid: DistanceGrid | Один план строк и его суммарная стоимость. boundedFillFeasible хранит результат заполнения в пределах, описанного в разделе об интервалах. Его записывает только оптимальный план, у жадного плана оно равно None. |
ParagraphResult | optimized: ParagraphPlan, greedy: ParagraphPlan, breakOpportunityCount: int | Оптимальный план, жадный план и число штрафов, которые можно выбрать |
Перечисления
| Тип | Значения | Описание |
|---|---|---|
BreakKind | KoreanCluster, LatinSpace, LatinHyphen, LatinSoftHyphen, ClosingPunctuation, HardFallback, ForcedEnd | Вид места разрыва |
BreakMode | Word, Character | Word применяет защиту слов, описанную у BreakPolicy. Character даёт все места, которые допускают UAX #14 и правила кинсоку, без этой защиты. Внутри латинского слова вроде Hello, где UAX #14 разрыва не даёт, Character тоже не разрывает. |
BreakRule | Allowed(int), Forced, Forbidden | Поведение штрафа. Forced всегда разрывает строку, Forbidden никогда не разрывает. Число n в Allowed(n) задаёт стоимость разрыва в этом месте. FixedPoint прибавляет n как есть, а KnuthPlass прибавляет n², если n не меньше 0, и вычитает n², если n отрицательно. |
CostModel | FixedPoint, KnuthPlass | Способ расчёта стоимости строки |
DistanceGrid | ScaledPoint, MilliPoint | Единица целых ширин |
JustificationMode | Unjustified, LatinWordSpace, CjkInterCharacter | Без выключки, выключка за счёт пробелов между словами, выключка между знаками CJK |
KinsokuRules | AtlasDefault, Unicode16Ko, Unicode16Zh, Unicode16Ja | Набор правил разрыва |
TypesetError | InvalidInput(string), InvalidPolicy(string), NoBreakOpportunities, NoGreedyPlan, NoPlan | Ошибки функций вёрстки |