Add support for drawing cutmarks on badges, not just borders
authorMagnus Hagander <magnus@hagander.net>
Wed, 17 Sep 2025 11:58:12 +0000 (13:58 +0200)
committerMagnus Hagander <magnus@hagander.net>
Wed, 17 Sep 2025 11:58:55 +0000 (13:58 +0200)
Cutmarks are what most printers want. Instead of having to hardcode them
into individual badges, add system support for drawing them
automatically.

In relation to this, also add support for calculating bleed for both
cutmarks and borders

docs/confreg/skinning.md
postgresqleu/confreg/jinjapdf.py
postgresqleu/confreg/reports.py
template/confreg/reports.html

index e42096e776e3e7ea74deeeedb21ce31d66780b07..c12011a30750099e67414423e9a63631eb6d5ccf 100644 (file)
@@ -293,11 +293,25 @@ system) in format `json`.
 At the root of the json structure, two elements have to be defined:
 width and height. All positions and sizes are defined in mm.
 
-If the element `border` is set to *true*, a thin black border will be
-drawn around the outer edges (for cutting). If the element
-is set to *false*, no border will be printed. If the element is not set
-at all, printing will be controlled from the form field when building the
-badges, and be off when building tickets.
+The element `border` controls borders and cutmarks for the badge.
+If it is set to *border* or to *true*, a thin black border will be
+drawn around the outer edges (for manual cutting).
+
+If `border` is set to *cutmarks* then outside cutmarks will be drawn
+on the badge. These marks will by defaut be *10mm* long, and placed *3mm*
+from the edge of the badge (with the intersection being right at the
+corner of the badge, of course). The length and offset can be overridden
+by setting `cutmark_length` and `cutmark_offset` respectively.
+
+The value of the `border` element can be overridden from the form field
+when building the badges from the web.
+
+if the element `bleed` is set to a value, border and cutmarks will be
+adjusted to bleed this much on each side of the badge. In practive,
+this means adjusting the border/marks inward by this many mm. For the
+resulting badge to be the expected size, the total size of the badge
+(as specified in `width` and `height`) should be increased by *2 *
+bleed* and the badge elements adjusted for that.
 
 If the element `forcebreaks` is set to *true*, a pagebreak will be forced
 between each badge, making sure there is only one badge per
index f10be6e1b8051358e9aa71dc5d0543b2f4c9b327..2b51ead15b4deffc9c5b475a7170cd1c49f30d76 100755 (executable)
@@ -27,6 +27,10 @@ try:
 except ImportError:
     import contextutil
 
+
+DEFAULT_CUTMARK_LENGTH = 8
+DEFAULT_CUTMARK_OFS = 3
+
 alignments = {
     'left': TA_LEFT,
     'center': TA_CENTER,
@@ -58,9 +62,6 @@ class JinjaFlowable(Flowable):
             self.hAlign = 'CENTER'
 
     def draw(self):
-        if self.js.get('border', False):
-            self.canv.rect(0, 0, self.width, self.height)
-
         for e in self.js['elements']:
             if e == {}:
                 continue
@@ -69,6 +70,29 @@ class JinjaFlowable(Flowable):
                 f(e)
             else:
                 raise Exception("Unknown type %s" % e['type'])
+        self._draw_border()
+
+    def _draw_border(self):
+        if self.js.get('border', False) in ('border', True, 1):
+            self.canv.rect(-1, -1, self.width + 2, self.height + 2)
+        elif self.js.get('border', False) == 'cutmarks':
+            cmlength = self.js.get('cutmark_length', DEFAULT_CUTMARK_LENGTH) * mm
+            cmofs = self.js.get('cutmark_offset', DEFAULT_CUTMARK_OFS) * mm
+            cmpos = cmlength + cmofs
+            bleed = self.js.get('bleed', 0) * mm
+
+            # Bottom left
+            self.canv.line(-cmpos + bleed, bleed, -cmofs + bleed, bleed)
+            self.canv.line(bleed, -cmpos + bleed, bleed, -cmofs + bleed)
+            # Bottom right
+            self.canv.line(self.width + cmofs - bleed, bleed, self.width + cmpos - bleed, bleed)
+            self.canv.line(self.width - bleed, -cmpos + bleed, self.width - bleed, -cmofs + bleed)
+            # Top left
+            self.canv.line(-cmpos + bleed, self.height - bleed, -cmofs + bleed, self.height - bleed)
+            self.canv.line(bleed, self.height + cmofs - bleed, bleed, self.height + cmpos - bleed)
+            # Top right
+            self.canv.line(self.width + cmofs - bleed, self.height - bleed, self.width + cmpos - bleed, self.height - bleed)
+            self.canv.line(self.width - bleed, self.height + cmofs - bleed, self.width - bleed, self.height + cmpos - bleed)
 
     def calc_y(self, o):
         return self.height - getmm(o, 'y') - getmm(o, 'height')
@@ -325,8 +349,14 @@ class JinjaRenderer(object):
             else:
                 raise Exception("JSON parse failed.")
 
-        if 'border' not in js:
-            js['border'] = self.border
+        # Potentially override border settings
+        if self.border == 0 or self.border == 'none':
+            js['border'] = ''
+        elif self.border == 1 or self.border == 'border':
+            js['border'] = 'border'
+        elif self.border == 2 or self.border == 'cutmarks':
+            js['border'] = 'cutmarks'
+
         self.story.append(JinjaFlowable(js, self.staticdir))
 
         if 'forcebreaks' not in js:
@@ -334,8 +364,17 @@ class JinjaRenderer(object):
         if js.get('forcebreaks', False):
             self.story.append(PageBreak())
 
+        self.js = js
+
     def render(self, output):
-        doc = SimpleDocTemplate(output, pagesize=self.pagesize, leftMargin=10 * mm, topMargin=5 * mm, rightMargin=10 * mm, bottomMargin=5 * mm)
+        leftMargin = 10 * mm
+        topMargin = 5 * mm
+        if self.js.get('border', None) == 'cutmarks':
+            cmsize = self.js.get('cutmark_length', DEFAULT_CUTMARK_LENGTH) * mm + self.js.get('cutmark_offset', DEFAULT_CUTMARK_OFS) * mm
+            topMargin += cmsize
+            leftMargin += cmsize
+
+        doc = SimpleDocTemplate(output, pagesize=self.pagesize, leftMargin=leftMargin, topMargin=topMargin, rightMargin=10 * mm, bottomMargin=5 * mm)
         doc.build(self.story)
 
 
@@ -388,7 +427,7 @@ if __name__ == "__main__":
     parser.add_argument('attendeelist', type=str, help='JSON file with attendee list')
     parser.add_argument('outputfile', type=str, help='Name of output PDF file')
     parser.add_argument('--confjson', type=str, help='JSON representing conference')
-    parser.add_argument('--borders', action='store_true', help='Enable borders on written file')
+    parser.add_argument('--borders', choices=['none', 'border', 'cutmarks'], help='Enable borders on written file')
     parser.add_argument('--pagebreaks', action='store_true', help='Enable pagebreaks on written file')
     parser.add_argument('--fontroot', type=str, help='fontroot for dejavu fonts')
     parser.add_argument('--font', type=str, nargs=1, action='append', help='<font name>:<font path>')
index 7ad064024a0386b9c81e5b95cfa5403733320893..15fd427d9d6b1c9b85ac40a326424868e5b9d692 100644 (file)
@@ -376,7 +376,7 @@ class ReportWriterPdf(ReportWriterBase):
         style = [
             ("FONTNAME", (0, 0), (-1, -1), "DejaVu Serif"),
             ]
-        if self.borders:
+        if self.borders == 1:
             style.extend([
                 ('GRID', (0, 0), (-1, -1), 1, colors.black),
                 ('BACKGROUND', (0, 0), (-1, 0), colors.lightgrey),
@@ -525,7 +525,14 @@ class AttendeeReportManager:
         format = data['format']
         orientation = data['orientation']
         pagesize = data.get('pagesize', 'A4')
-        borders = data.get('border', None) == "on"
+        borders = int(data.get('border', 0))
+        if borders == 2 and format != 'badge':
+            return HttpResponse("Cutmarks can only be used for badges", content_type='text/plain')
+
+        # Default borders to on for non-badges (for badges, default is read from inside the badge)
+        if borders == -1 and format != 'badge':
+            borders = 1
+
         pagebreaks = data.get('pagebreaks', None) == 'on'
         extracols = [_f for _f in [x.strip() for x in data['additionalcols'].split(',')] if _f]
         ofields = [self.fieldmap[f] for f in (data['orderby1'], data['orderby2'])]
index 82067ea4e6bb4cca624a20377dc233f41aeade34..39d49f34ba4594bf7c10aacbd82df1218ca452f1 100644 (file)
@@ -138,10 +138,18 @@ input#dlgSelectFieldCountText {
          <td><select name="pagesize" id="selPagesize"><option value="A4">A4</option><option value="letter">Letter</option></select></td>
        </tr>
        <tr>
-         <td>Borders</td>
-         <td><input type="checkbox" name="border" CHECKED id="cbBorders"> include table and badge borders</td>
+         <td>PDF Borders:</td>
+    <td>
+      <select name="border">
+        <option value="-1" SELECTED>Default</option>
+        <option value="0">No borders</option>
+        <option value="1">Borders</option>
+        <option value="2">Cutmarks (only for badges)</option>
+      </select>
+    </td>
+  </tr>
        <tr>
-         <td>Pagebreaks</td>
+         <td>PDF Pagebreaks:</td>
          <td><input type="checkbox" name="pagebreaks" CHECKED id="cbPagebreaks"> force page break (between badges only)</td>
        </tr>
       </table>