How to improve layout of long multiplication and add decimal arithmetic to arithmetic alogorithms

Below are maths algorithms for Long Multiplication, Addition and Subtraction, and Long Division. Addition developed by @SillyFreak and the rest modified from this.

Long Division, I cannot get to look pretty and keen on some feedback to make it look nicer:

Some other improvements if anyone can suggest:

  • Support for decimal arithmetic

Hopefully someone can help make the final one!

#set text(3em)

// convert a number to a string, split it into characters, and convert it back into digits
#let digits(x) = str(x).clusters().map(int)

#let algorithm_add(..summands) = {
  assert.eq(summands.named(), (:))
  // represent each summand as an array, least significant digit first
  let summands = summands.pos().map(x => digits(x).rev())
  let len = calc.max(..summands.map(array.len))

  // do the summation, digit by digit
  let columns = ()
  let carry = 0
  for i in range(len) {
    // get all the digits
    let summands = summands.map(x => x.at(i, default: none))
    // calculate sum, split into ones and tens
    let sum = summands.sum() + carry
    let (sum, new-carry) = (calc.rem(sum, 10), int(sum/10))
    // save the result, always prepending so that the most significant
    // digits end up left
    columns.insert(0, (
      summands: summands,
      sum: sum,
      carry: carry,
    ))
    carry = new-carry
  }
  // add a final column if there's a carry
  if carry != 0 {
    columns.insert(0, (
      summands: (none,) * summands.len(),
      sum: carry,
      carry: 0,
    ))
  }
  // add a dummy column for the addition sign
  columns.insert(0, none)

  block(breakable: false)[
    #grid(
      columns: columns.len(),
      inset: 0.1em,
      // go through all summands
      ..range(summands.len()).map(r => {
        // go through all columns
        columns.enumerate().map(((c, col)) => {
          if col == none {
            // this is the first column
            if r == summands.len() - 1 [+]
            else []
          } else {
            // this is a regular column
            if r == 0 and col.carry != 0 {
              // first line: put the carry
              place(dx: -0.15em, dy: -0.1em, text(0.3em)[#col.carry])
            }
            // put the digit
            [#col.summands.at(r)]
          }
        })
      }).flatten(),
      grid.hline(),
      // sum
      ..columns.enumerate().map(((c, col)) => {
        if col == none []
        else [#col.sum]
      }),
    )
  ]
}

#let algorithm_mult(multiplicand, multiplier) = {
  // represent numbers as arrays, least significant digit first
  let multiplicand-digits = digits(multiplicand).rev()
  let multiplier-digits = digits(multiplier).rev()
  
  // calculate all partial products with carry information
  let partial-products = ()
  for (i, mult-digit) in multiplier-digits.enumerate() {
    if mult-digit == 0 {
      // skip zero multipliers but maintain position
      partial-products.push((digits: (0,), carries: (), offset: i))
      continue
    }
    
    let product-digits = ()
    let carries = ()
    let carry = 0
    
    // multiply each digit of multiplicand by current multiplier digit
    for (j, digit) in multiplicand-digits.enumerate() {
      let product = digit * mult-digit + carry
      product-digits.push(calc.rem(product, 10))
      let new-carry = int(product / 10)
      // Store the carry that will be used for the NEXT position
      if new-carry > 0 and j < multiplicand-digits.len() - 1 {
        carries.push((pos: j + 1, value: new-carry))
      }
      carry = new-carry
    }
    
    // add final carry if exists
    if carry > 0 {
      product-digits = product-digits + digits(carry).rev()
    }
    
    partial-products.push((digits: product-digits, carries: carries, offset: i))
  }
  
  // calculate final sum by adding all partial products
  let max-len = calc.max(..partial-products.map(p => p.digits.len() + p.offset))
  let sum-digits = (0,) * max-len
  let carry = 0
  
  for pos in range(max-len) {
    let sum = carry
    for product in partial-products {
      let digit-pos = pos - product.offset
      if digit-pos >= 0 and digit-pos < product.digits.len() {
        sum += product.digits.at(digit-pos)
      }
    }
    sum-digits.at(pos) = calc.rem(sum, 10)
    carry = int(sum / 10)
  }
  
  // Build column structure
  let total-width = calc.max(multiplicand-digits.len(), multiplier-digits.len(), sum-digits.len()) + 1
  let num-carry-rows = multiplier-digits.len() // One row of carries per multiplier digit
  
  // Initialize columns
  let columns = ()
  for i in range(total-width) {
    columns.push(())
  }
  
  // Add carry rows (above multiplicand)
  for carry-row in range(num-carry-rows) {
    columns.at(0).push(none) // no operator for carry rows
    let multiplicand-start = total-width - multiplicand-digits.len()
    
    // Add carries for this step - use reverse order so bottom carry row corresponds to first multiplier digit
    let product-index = num-carry-rows - 1 - carry-row
    if product-index < partial-products.len() {
      let product = partial-products.at(product-index)
      for (i, digit) in multiplicand-digits.rev().enumerate() {
        let col-pos = multiplicand-start + i
        let multiplicand-pos = multiplicand-digits.len() - 1 - i
        
        // Check if there's a carry for this position in this step
        let carry-value = none
        for carry-info in product.carries {
          if carry-info.pos == multiplicand-pos {
            carry-value = carry-info.value
            break
          }
        }
        
        if carry-value != none {
          columns.at(col-pos).push(place(dy: 0em, dx: 0.21em, text(0.3em)[#carry-value]))
        } else {
          columns.at(col-pos).push(none)
        }
      }
    }
    
    // Fill empty columns with none
    for i in range(1, multiplicand-start) {
      columns.at(i).push(none)
    }
  }
  
  // Add multiplicand (right-aligned)
  columns.at(0).push(none)
  let multiplicand-start = total-width - multiplicand-digits.len()
  for (i, digit) in multiplicand-digits.rev().enumerate() {
    columns.at(multiplicand-start + i).push(digit)
  }
  // Fill empty columns with none
  for i in range(1, multiplicand-start) {
    columns.at(i).push(none)
  }
  
  // Add multiplier with × symbol (right-aligned)
  columns.at(0).push([×])
  let multiplier-start = total-width - multiplier-digits.len()
  for (i, digit) in multiplier-digits.rev().enumerate() {
    columns.at(multiplier-start + i).push(digit)
  }
  // Fill empty columns with none
  for i in range(1, multiplier-start) {
    columns.at(i).push(none)
  }
  
  // Add partial products (if multi-digit multiplier)
  if multiplier-digits.len() > 1 {
    for product in partial-products {
      if product.digits != (0,) {
        columns.at(0).push(none)
        
        // Add product digits right-aligned with proper offset
        let product-start = total-width - product.digits.len() - product.offset
        for (i, digit) in product.digits.rev().enumerate() {
          columns.at(product-start + i).push(digit)
        }
        // Fill empty columns with none
        for i in range(1, product-start) {
          columns.at(i).push(none)
        }
        for i in range(product-start + product.digits.len(), total-width) {
          columns.at(i).push(none)
        }
      }
    }
  }
  
  // Add final sum (right-aligned)
  columns.at(0).push(none)
  let sum-start = total-width - sum-digits.len()
  for (i, digit) in sum-digits.rev().enumerate() {
    columns.at(sum-start + i).push(digit)
  }
  // Fill empty columns with none
  for i in range(1, sum-start) {
    columns.at(i).push(none)
  }
  
  // Calculate number of rows for each column
  let num-rows = columns.at(0).len()
  let multiplicand-row = num-carry-rows
  let multiplier-row = num-carry-rows + 1
  
  block(breakable: false)[
    #grid(
      columns: total-width,
      inset: (x, y) => {
        // Reduce horizontal spacing for carry rows (first num-carry-rows rows)
        if y < num-carry-rows {
          (x: 0.05em, y: 0.15em)
        } else {
          0.1em
        }
      },
      
      // Render all rows
      ..range(num-rows).map(r => {
        // Add horizontal line after multiplier
        if r == multiplier-row + 1 {
          range(total-width).map(_ => grid.hline())
        }
        // Add horizontal line before sum (last row)
        else if r == num-rows - 1 {
          range(total-width).map(_ => grid.hline())
        }
        
        // Render row content
        columns.map(col => {
          let cell = col.at(r, default: none)
          if cell == none { [] } else { [#cell] }
        })
      }).flatten()
    )
  ]
}

#let algorithm_subtract(minuend, subtrahend) = {
  // determine if result will be negative
  let is-negative = minuend < subtrahend
  
  // if result would be negative, swap the numbers
  let (top-num, bottom-num) = if is-negative {
    (subtrahend, minuend)
  } else {
    (minuend, subtrahend)
  }
  
  // represent numbers as arrays, least significant digit first
  let minuend-digits = digits(top-num).rev()
  let subtrahend-digits = digits(bottom-num).rev()
  
  let max-len = calc.max(minuend-digits.len(), subtrahend-digits.len())
  
  // pad with zeros if needed
  while minuend-digits.len() < max-len {
    minuend-digits.push(0)
  }
  while subtrahend-digits.len() < max-len {
    subtrahend-digits.push(0)
  }
  
  // perform subtraction with borrowing, tracking changes
  let result-digits = ()
  let working-minuend = minuend-digits.map(x => x) // deep copy
  let borrow-info = () // track borrowing details for each position
  
  for i in range(max-len) {
    let min-digit = working-minuend.at(i)
    let sub-digit = subtrahend-digits.at(i)
    
    // check if we need to borrow
    if min-digit < sub-digit {
      // find the next non-zero digit to borrow from
      let borrow-pos = i + 1
      let borrow-chain = () // track the chain of borrowing
      
      // handle borrowing through zeros
      while borrow-pos < max-len and working-minuend.at(borrow-pos) == 0 {
        borrow-chain.push(borrow-pos)
        working-minuend.at(borrow-pos) = 9
        borrow-pos += 1
      }
      
      if borrow-pos < max-len {
        // borrow from this position
        working-minuend.at(borrow-pos) -= 1
        borrow-chain.push(borrow-pos)
        working-minuend.at(i) += 10
        
        // record borrowing info for visual display
        for pos in borrow-chain {
          borrow-info.push((position: pos, borrowed-from: true))
        }
        borrow-info.push((position: i, borrowed-to: true))
      }
    }
    
    // perform the subtraction
    let diff = working-minuend.at(i) - sub-digit
    result-digits.push(diff)
  }
  
  // remove leading zeros from result
  while result-digits.len() > 1 and result-digits.at(-1) == 0 {
    let _ = result-digits.pop()
  }
  
  // Build the visual representation
  let total-width = max-len + 1
  let has-borrows = borrow-info.len() > 0
  
  // Initialize columns
  let columns = ()
  for i in range(total-width) {
    columns.push(())
  }
  
  // Add borrow row (above minuend) if there are any borrows
  if has-borrows {
    columns.at(0).push(none)
    for (i, original-digit) in minuend-digits.rev().enumerate() {
      let col-pos = i + 1
      let digit-pos = max-len - 1 - i
      
      // Check if this position was borrowed from
      let was-borrowed-from = original-digit != working-minuend.at(digit-pos)
      
      if was-borrowed-from {
        // Show small digit above the crossed-out original
        columns.at(col-pos).push(
          place(dx: 0em, dy: -0.15em, text(0.4em)[#working-minuend.at(digit-pos)])
        )
      } else {
        columns.at(col-pos).push(none)
      }
    }
  }
  
  // Add minuend row (with crossed out digits where borrowed from)
  columns.at(0).push(none)
  for (i, original-digit) in minuend-digits.rev().enumerate() {
    let col-pos = i + 1
    let digit-pos = max-len - 1 - i
    
    // Check if this position was borrowed from
    let was-borrowed-from = original-digit != working-minuend.at(digit-pos)
    
    if was-borrowed-from {
      // Show crossed out original digit
      columns.at(col-pos).push(text(strike[#original-digit]))
    } else {
      // Show original digit
      columns.at(col-pos).push(original-digit)
    }
  }
  
  // Add subtrahend with − symbol
  columns.at(0).push([−])
  for (i, digit) in subtrahend-digits.rev().enumerate() {
    let col-pos = i + 1
    if digit != 0 or subtrahend-digits.rev().slice(0, i+1).any(d => d != 0) {
      columns.at(col-pos).push(digit)
    } else {
      columns.at(col-pos).push(none)
    }
  }
  
  // Add result with negative sign if needed
  if is-negative {
    columns.at(0).push([−])
  } else {
    columns.at(0).push(none)
  }
  
  // Add result digits
  for (i, digit) in result-digits.rev().enumerate() {
    let col-pos = max-len - result-digits.len() + i + 1
    columns.at(col-pos).push(digit)
  }
  // Fill empty columns in result row
  for i in range(1, max-len - result-digits.len() + 1) {
    columns.at(i).push(none)
  }
  
  // Calculate number of rows
  let num-rows = columns.at(0).len()
  let subtrahend-row = if has-borrows { 2 } else { 1 }
  
  block(breakable: false)[
    #grid(
      columns: total-width,
      inset: (x, y) => {
        // Reduce spacing for borrow row
        if y == 0 and has-borrows {
          (x: 0.05em, y: 0.05em)
        } else {
          0.1em
        }
      },
      
      // Render all rows
      ..range(num-rows).map(r => {
        // Add horizontal line after subtrahend
        if r == subtrahend-row + 1 {
          range(total-width).map(_ => grid.hline())
        }
        
        // Render row content
        columns.map(col => {
          let cell = col.at(r, default: none)
          if cell == none { [] } else { [#cell] }
        })
      }).flatten()
    )
  ]
}

// Long division algorithm with proper step-by-step display
#let algorithm_long_divide(dividend, divisor) = {
  let dividend-digits = digits(dividend)
  let divisor-int = divisor
  
  // Track the division process
  let quotient-digits = ()
  let steps = () // Each step contains: partial-dividend, quotient-digit, subtraction, remainder, bring-down-digit
  
  let current-dividend = 0
  let working-dividend = 0
  
  // Process each digit of the dividend
  for (i, digit) in dividend-digits.enumerate() {
    // Bring down the next digit
    working-dividend = current-dividend * 10 + digit
    
    // Calculate quotient digit (0 if working dividend is smaller than divisor)
    let quotient-digit = if working-dividend >= divisor-int {
      calc.floor(working-dividend / divisor-int)
    } else { 0 }
    
    quotient-digits.push(quotient-digit)
    
    // Calculate what we subtract
    let subtraction = quotient-digit * divisor-int
    
    // Calculate remainder
    let remainder = working-dividend - subtraction
    
    // Calculate the rightmost position of this working dividend
    let working-end-pos = i
    let working-start-pos = working-end-pos - str(working-dividend).len() + 1
    
    // Store step information
    steps.push((
      working-dividend: working-dividend,
      quotient-digit: quotient-digit,
      subtraction: subtraction,
      remainder: remainder,
      position: i,
      brought-down: digit,
      working-start-pos: working-start-pos,
      working-end-pos: working-end-pos
    ))
    
    // The remainder becomes the start of the next division
    current-dividend = remainder
  }
  
  // Remove leading zeros from quotient
  while quotient-digits.len() > 1 and quotient-digits.first() == 0 {
    quotient-digits = quotient-digits.slice(1)
  }
  
  // Calculate layout dimensions
  let dividend-width = dividend-digits.len()
  let quotient-width = quotient-digits.len()
  let divisor-width = str(divisor).len()
  let digit-width = 0.6em
  let divisor-space = divisor-width * digit-width + 0.3em
  
  block(breakable: false)[
    #set align(left)
    
    // Top section: divisor, division bar, quotient, and dividend
    #stack(
      dir: ttb,
      spacing: 0.15em,
      
      // Quotient line with remainder if any - right aligned above dividend
      stack(
        dir: ltr,
        spacing: 0em,
        h(divisor-space + 0.5em), // Space for divisor and division symbol
        // Add spacing to right-align quotient above dividend
        h((dividend-digits.len() - quotient-digits.len()) * digit-width),
        ..quotient-digits.map(d => box(width: digit-width, align(center)[#d])),
        if steps.last().remainder > 0 {
          h(0.3em)
          text(0.9em)[r]
          text(size: 1em)[#steps.last().remainder]
        }
      ),
      
      // Division symbol, horizontal line, and dividend (all on same line)
      stack(
        dir: ltr,
        spacing: 0.05em,
        box(width: divisor-space - 0.1em, align(right)[#str(divisor)]),
        box(width: 0.5em, align(center)[)]),
        stack(
          dir: ttb,
          spacing: 0em,
          line(length: dividend-digits.len() * digit-width, stroke: 0.8pt),
          stack(
            dir: ltr,
            spacing: 0em,
            ..dividend-digits.map(d => box(width: digit-width, align(center)[#d]))
          )
        )
      ),
      
      // Working steps
      ..steps.enumerate().map(((step-idx, step)) => {
        if step.quotient-digit > 0 { // Only show steps where we actually divide
          let base-indent = divisor-space + 0.5em
          
          // Position the subtraction to align with the working dividend
          let step-indent = base-indent + step.working-start-pos * digit-width
          
          stack(
            dir: ttb,
            spacing: 0.05em,
            
            // Subtraction line (what we multiply and subtract)
            stack(
              dir: ltr,
              spacing: 0em,
              h(step-indent),
              ..digits(step.subtraction).map(d => box(width: digit-width, align(center)[#d]))
            ),
            
            // Underline for subtraction
            stack(
              dir: ltr,
              spacing: 0em,
              h(step-indent),
              line(length: str(step.subtraction).len() * digit-width, stroke: 0.6pt)
            ),
            
            // Remainder and brought down digit (if applicable)
            if step-idx < steps.len() - 1 {
              let next-step = steps.at(step-idx + 1)
              let remainder-digits = if step.remainder == 0 { () } else { digits(step.remainder) }
              let combined-digits = remainder-digits + (next-step.brought-down,)
              
              // Position the combined number to align properly
              // The remainder should align right under the subtraction, then bring down next digit
              let remainder-start = step.working-end-pos - remainder-digits.len() + 1
              let combined-indent = base-indent + remainder-start * digit-width
              
              stack(
                dir: ltr,
                spacing: 0em,
                h(combined-indent),
                ..combined-digits.map(d => box(width: digit-width, align(center)[#d]))
              )
            } else if step.remainder > 0 {
              // Final remainder - align with the subtraction
              let remainder-digits = digits(step.remainder)
              let remainder-indent = step-indent + (str(step.subtraction).len() - remainder-digits.len()) * digit-width
              
              stack(
                dir: ltr,
                spacing: 0em,
                h(remainder-indent),
                ..remainder-digits.map(d => box(width: digit-width, align(center)[#d]))
              )
            }
          )
        }
      }).filter(x => x != none)
    )
  ]
}

#algorithm_add(744, 2480)
#algorithm_mult(409,49)
#algorithm_subtract(542, 1876)
#algorithm_subtract(50, 123)


// Test cases
#algorithm_long_divide(84, 4)
#v(1em)
#algorithm_long_divide(19, 12)
#v(1em)
#algorithm_long_divide(2847, 23)

1 Like

I don’t want to sound petty, but some proper attribution would be nice. The addition algorithm was my creation: Discord

That said, I’m glad you posted this here, since on Discord nobody would have ever seen it again.


Just to be safe, I’ll post it here again so that there is no doubt anyone can reuse this code according to the CC-BY 4.0 license:

the addition algorithm
#set text(3em)

// convert a number to a string, split it into characters, and convert it back into digits
#let digits(x) = str(x).clusters().map(int)

#let summation(..summands) = {
  assert.eq(summands.named(), (:))
  // represent each summand as an array, least significant digit first
  let summands = summands.pos().map(x => digits(x).rev())
  let len = calc.max(..summands.map(array.len))

  // do the summation, digit by digit
  let columns = ()
  let carry = 0
  for i in range(len) {
    // get all the digits
    let summands = summands.map(x => x.at(i, default: none))
    // calculate sum, split into ones and tens
    let sum = summands.sum() + carry
    let (sum, new-carry) = (calc.rem(sum, 10), int(sum/10))
    // save the result, always prepending so that the most significant
    // digits end up left
    columns.insert(0, (
      summands: summands,
      sum: sum,
      carry: carry,
    ))
    carry = new-carry
  }
  // add a final column if there's a carry
  if carry != 0 {
    columns.insert(0, (
      summands: (none,) * summands.len(),
      sum: carry,
      carry: 0,
    ))
  }
  // add a dummy column for the addition sign
  columns.insert(0, none)

  grid(
    columns: columns.len(),
    inset: 0.1em,
    // go through all summands
    ..range(summands.len()).map(r => {
      // go through all columns
      columns.enumerate().map(((c, col)) => {
        if col == none {
          // this is the first column
          if r == summands.len() - 1 [+]
          else []
        } else {
          // this is a regular column
          if r == 0 and col.carry != 0 {
            // first line: put the carry
            place(dx: -0.15em, dy: -0.1em, text(0.4em)[#col.carry])
          }
          // put the digit
          [#col.summands.at(r)]
        }
      })
    }).flatten(),
    grid.hline(),
    // sum
    ..columns.enumerate().map(((c, col)) => {
      if col == none []
      else [#col.sum]
    }),
  )
}

#summation(744, 2480)
6 Likes

Yep sorry poor form on my end and purely an oversight. Will amend this post!

2 Likes