///|
/// Reading direction of the bound booklet.
pub(all) enum Binding {
Left
Right
} derive(Eq)
///|
/// Explicit methods keep Eq usable without deprecated implicit promotion.
pub fn Binding::equal(self : Binding, other : Binding) -> Bool {
match (self, other) {
(Left, Left) | (Right, Right) => true
_ => false
}
}
///|
pub fn Binding::not_equal(self : Binding, other : Binding) -> Bool {
!self.equal(other)
}
///|
/// Landscape duplex edge. Long-edge backs need a 180-degree content rotation.
pub(all) enum Duplex {
ShortEdge
LongEdge
} derive(Eq)
///|
pub fn Duplex::equal(self : Duplex, other : Duplex) -> Bool {
match (self, other) {
(ShortEdge, ShortEdge) | (LongEdge, LongEdge) => true
_ => false
}
}
///|
pub fn Duplex::not_equal(self : Duplex, other : Duplex) -> Bool {
!self.equal(other)
}
///|
/// Each signature is a separately folded and nested gathering.
pub(all) struct Config {
signature_pages : Int32
binding : Binding
duplex : Duplex
}
///|
/// Page 0 means an inserted blank. All other values are original document pages.
pub(all) struct Sheet {
signature : Int32
depth : Int32
front_left : Int32
front_right : Int32
back_left : Int32
back_right : Int32
back_rotation : Int32
}
///|
/// Array order is signature order, then outermost to innermost sheet.
pub(all) struct Plan {
source_pages : Int32
selected : Array[Int32]
padded_pages : Int32
blanks : Int32
signatures : Int32
config : Config
sheets : Array[Sheet]
}
///|
pub fn default_config() -> Config {
{
// Four sheets per separately folded gathering.
signature_pages: 16,
// Faces are viewed from their own printed side.
binding: Left,
// Landscape short-edge duplex requires no back rotation.
duplex: ShortEdge,
}
}
///|
/// Strict bounded integer parser used by the selection grammar and CLI.
pub fn parse_positive(text : String, maximum : Int32) -> Result[Int32, String] {
if text == "" || maximum < 1 {
return Err("expected a positive integer")
}
let mut value = 0
for ch in text {
if ch < '0' || ch > '9' {
return Err("expected decimal digits only")
}
let digit = ch.to_int() - '0'.to_int()
if digit > maximum || value > (maximum - digit) / 10 {
return Err("number exceeds supported limit")
}
value = value * 10 + digit
}
if value == 0 {
Err("zero is not allowed")
} else {
Ok(value)
}
}
///| A comma-separated list of pages and inclusive ascending ranges.
///|
/// Order is preserved. Duplicate, descending and out-of-document pages are errors.
pub fn parse_selection(
text : String,
total : Int32,
) -> Result[Array[Int32], String] {
if total < 1 || total > 10000 {
return Err("document page count must be 1..10000")
}
if text.trim().to_owned() == "" {
return Err("selection must not be empty")
}
let pages : Array[Int32] = []
let seen = Array::make(total + 1, false)
for part in text.split(",") {
let range = part.trim().to_owned().split("-").to_array()
if range.length() < 1 || range.length() > 2 {
return Err("invalid page range")
}
let first = match parse_positive(range[0].trim().to_owned(), total) {
Ok(n) => n
Err(e) => return Err(e)
}
let last = if range.length() == 2 {
match parse_positive(range[1].trim().to_owned(), total) {
Ok(n) => n
Err(e) => return Err(e)
}
} else {
first
}
if last < first {
return Err("descending ranges are not supported")
}
for p = first; p <= last; p = p + 1 {
if seen[p] {
return Err("selection repeats page " + p.to_string())
}
seen[p] = true
pages.push(p)
}
}
Ok(pages)
}
///| Validate first, then construct a deterministic booklet plan without I/O.
///|
/// Last signature is padded only to the nearest multiple of four.
pub fn make_plan(
total : Int32,
selected : Array[Int32],
config : Config,
) -> Result[Plan, String] {
if total < 1 || total > 10000 {
return Err("document page count must be 1..10000")
}
if config.signature_pages < 4 ||
config.signature_pages > 64 ||
config.signature_pages % 4 != 0 {
return Err("signature size must be a multiple of 4 in 4..64")
}
if selected.length() == 0 {
return Err("selection must not be empty")
}
let seen = Array::make(total + 1, false)
for page in selected {
if page < 1 || page > total {
return Err("selected page is outside the document")
}
if seen[page] {
return Err("selection repeats page " + page.to_string())
}
seen[page] = true
}
let sheets : Array[Sheet] = []
let mut offset = 0
let mut signatures = 0
let mut padded_pages = 0
while offset < selected.length() {
signatures = signatures + 1
let remaining = selected.length() - offset
let count = if remaining < config.signature_pages {
remaining
} else {
config.signature_pages
}
let padded = (count + 3) / 4 * 4
padded_pages = padded_pages + padded
for depth = 0; depth < padded / 4; depth = depth + 1 {
let fl = slot(selected, offset, count, padded - 1 - depth * 2)
let fr = slot(selected, offset, count, depth * 2)
let bl = slot(selected, offset, count, depth * 2 + 1)
let br = slot(selected, offset, count, padded - 2 - depth * 2)
let right = config.binding == Right
sheets.push({
signature: signatures,
depth: depth + 1,
front_left: if right {
fr
} else {
fl
},
front_right: if right {
fl
} else {
fr
},
back_left: if right {
br
} else {
bl
},
back_right: if right {
bl
} else {
br
},
back_rotation: if config.duplex == LongEdge {
180
} else {
0
},
})
}
offset = offset + count
}
Ok({
source_pages: total,
selected: selected.copy(),
padded_pages,
blanks: padded_pages - selected.length(),
signatures,
config,
sheets,
})
}
///|
fn slot(
pages : Array[Int32],
offset : Int32,
count : Int32,
index : Int32,
) -> Int32 {
if index >= count {
0
} else {
pages[offset + index]
}
}
///|
pub fn plan_to_json(plan : Plan) -> String {
let rows : Array[String] = []
for sheet in plan.sheets {
rows.push(
"{\"signature\":" +
sheet.signature.to_string() +
",\"depth\":" +
sheet.depth.to_string() +
",\"front\":[" +
sheet.front_left.to_string() +
"," +
sheet.front_right.to_string() +
"],\"back\":[" +
sheet.back_left.to_string() +
"," +
sheet.back_right.to_string() +
"],\"back_rotation\":" +
sheet.back_rotation.to_string() +
"}",
)
}
"{\"schema_version\":1,\"blank_page\":0,\"source_pages\":" +
plan.source_pages.to_string() +
",\"selected\":[" +
plan.selected.map(fn(n) { n.to_string() }).join(",") +
"],\"signature_pages\":" +
plan.config.signature_pages.to_string() +
",\"binding\":\"" +
(if plan.config.binding == Left { "left" } else { "right" }) +
"\",\"duplex\":\"" +
(if plan.config.duplex == ShortEdge { "short" } else { "long" }) +
"\",\"padded_pages\":" +
plan.padded_pages.to_string() +
",\"blanks\":" +
plan.blanks.to_string() +
",\"signatures\":" +
plan.signatures.to_string() +
",\"sheets\":[" +
rows.join(",") +
"]}\n"
}
///|
fn page_label(page : Int32) -> String {
if page == 0 {
"BLANK"
} else {
page.to_string()
}
}
///|
pub fn plan_to_text(plan : Plan) -> String {
let lines = [
"FoldLedger | " +
plan.selected.length().to_string() +
" selected pages | " +
plan.sheets.length().to_string() +
" sheets | " +
plan.blanks.to_string() +
" blanks",
"Signature / sheet : FRONT left | right ; BACK left | right ; back rotation",
]
for sheet in plan.sheets {
lines.push(
sheet.signature.to_string() +
" / " +
sheet.depth.to_string() +
" : " +
page_label(sheet.front_left) +
" | " +
page_label(sheet.front_right) +
" ; " +
page_label(sheet.back_left) +
" | " +
page_label(sheet.back_right) +
" ; " +
sheet.back_rotation.to_string(),
)
}
lines.join("\n") + "\n"
}
///| Accessible diagram of at most 24 sheets; JSON always includes the full plan.
///|
/// Faces are viewed separately from their own printed side before duplex rotation.
pub fn plan_to_svg(plan : Plan) -> String {
let shown = if plan.sheets.length() > 24 { 24 } else { plan.sheets.length() }
let height = 172 + shown * 154
let parts = [
"\n")
parts.join("")
}
///|
fn face_svg(
x : Int32,
y : Int32,
left : Int32,
right : Int32,
label : String,
rotation : Int32,
) -> String {
let center = x + 220
"" +
label +
" / rotate " +
rotation.to_string() +
"" +
page_label(left) +
"" +
page_label(right) +
""
}