11 changed files with 611 additions and 12 deletions
@ -0,0 +1,6 @@
@@ -0,0 +1,6 @@
|
||||
/* © SRSoftware 2025 */ |
||||
package de.srsoftware.umbrella.documents; |
||||
|
||||
public interface PriceFormat { |
||||
String format(long val); |
||||
} |
||||
@ -0,0 +1,188 @@
@@ -0,0 +1,188 @@
|
||||
/* © SRSoftware 2025 */ |
||||
package de.srsoftware.umbrella.documents; |
||||
|
||||
import de.srsoftware.configuration.JsonConfig; |
||||
import de.srsoftware.document.api.*; |
||||
import de.srsoftware.umbrella.core.model.UmbrellaUser; |
||||
import org.json.JSONObject; |
||||
|
||||
import java.util.*; |
||||
import java.util.regex.Pattern; |
||||
|
||||
import static de.srsoftware.tools.MimeType.*; |
||||
import static de.srsoftware.umbrella.core.Constants.ERROR_MISSING_FIELD; |
||||
import static de.srsoftware.umbrella.core.Constants.USER; |
||||
import static de.srsoftware.umbrella.documents.Constants.FIELD_PRICE_FORMAT; |
||||
import static java.lang.System.Logger.Level.TRACE; |
||||
import static java.nio.charset.StandardCharsets.UTF_8; |
||||
import static java.text.MessageFormat.format; |
||||
|
||||
public abstract class TemplateDoc implements Document { |
||||
private static final Pattern TOKEN_PATTERN = Pattern.compile("<\\? (([^?]|\\?[^>])+) \\?>"); |
||||
private static final Pattern POSITION_PATTERN = Pattern.compile("<!-- positions -->(.*)<!-- positions -->",Pattern.DOTALL); |
||||
private static final Pattern TAXES_PATTERN = Pattern.compile("<!-- tax list -->(.*)<!-- tax list -->",Pattern.DOTALL); |
||||
private String id, mime; |
||||
|
||||
@Override |
||||
public String description() { |
||||
return format("File created by TemplateProcessor from \"{0}\"",source().name()); |
||||
} |
||||
|
||||
@Override |
||||
public String id() { |
||||
if (id == null) { |
||||
id = source().name(); |
||||
if (id.endsWith(".template")) id = id.substring(0,id.length()-9); |
||||
} |
||||
return id; |
||||
} |
||||
|
||||
@Override |
||||
public String name() { |
||||
return "processed "+id(); |
||||
} |
||||
|
||||
@Override |
||||
public String mimeType() { |
||||
if (mime == null) { |
||||
mime = MIME_UNKNOWN; |
||||
if (id().endsWith("html")) mime = MIME_HTML; |
||||
if (id().endsWith("tex")) mime = MIME_LATEX; |
||||
} |
||||
return mime; |
||||
} |
||||
|
||||
@Override |
||||
public RenderResult render(Map<String, Object> data) { |
||||
data = new HashMap<>(data); |
||||
if (!(data.get(FIELD_PRICE_FORMAT) instanceof PriceFormat price)) return RenderError.of(ERROR_MISSING_FIELD,FIELD_PRICE_FORMAT); |
||||
if (data.get(USER) instanceof UmbrellaUser user) data.put(USER,user.toMap()); |
||||
var precursor = source().render(data); |
||||
if (precursor instanceof Content content){ |
||||
var source = new String(content.bytes(), UTF_8); |
||||
|
||||
var config = new JsonConfig(new JSONObject(data)); |
||||
source = renderPositions(source,config.subset("document.positions"),price); |
||||
source = renderTaxes(source,config.subset("document.taxes"),price); |
||||
var tokens = findTokensIn(source); |
||||
for (var token : tokens){ |
||||
var val = config.get(token).map(Object::toString); |
||||
if (val.isPresent()) { |
||||
var value = val.get().trim(); |
||||
value = switch (token){ |
||||
case "document.net_sum","document.gross_sum" -> price.format(Long.parseLong(value)); |
||||
default -> value; |
||||
}; |
||||
if (MIME_HTML.equals(mimeType())) value = value.replace("\n","<span class=\"break\"></span>\n"); |
||||
source = source.replace("<? "+token+" ?>",value); |
||||
} |
||||
} |
||||
tokens = findTokensIn(source); |
||||
for (var token : tokens){ |
||||
var value = config.get("translations."+token).map(Object::toString).orElse(token).trim(); |
||||
if (MIME_HTML.equals(mimeType())) value = value.replace("\n","<span class=\"break\"></span>\n"); |
||||
source = source.replace("<? "+token+" ?>",value); |
||||
} |
||||
return new StringContent(source); |
||||
} |
||||
return precursor; |
||||
} |
||||
|
||||
private String renderPositions(String source, Optional<JsonConfig> opt, PriceFormat price) { |
||||
if (opt.isPresent()){ |
||||
var positions = opt.get(); |
||||
System.getLogger(id()).log(TRACE,"Positions: {0}",positions); |
||||
|
||||
var matcher = POSITION_PATTERN.matcher(source); |
||||
if (matcher.find()){ |
||||
var posCode = matcher.group(1).trim(); |
||||
var start = matcher.start(1); |
||||
var end = matcher.end(1); |
||||
var tokens = findTokensIn(posCode); |
||||
StringBuilder sb = new StringBuilder(); |
||||
sb.append(source, 0, start); |
||||
for (var pos : positions.keys().stream().sorted(this::byIntValue).toList()){ |
||||
var copy = posCode; |
||||
for (var token : tokens){ |
||||
var key = pos+"."+token; |
||||
var val = positions.get(key).orElse(token); |
||||
String value = switch (token){ |
||||
case "amount" -> ""+(Math.round(1000d*(double)val)/1000d); |
||||
case "net_price", "unit_price" -> price.format((long)val); |
||||
case "optional" -> val == Boolean.TRUE ? "optional" : ""; |
||||
case null -> ""; |
||||
default -> val+""; |
||||
}; |
||||
copy = copy.replace("<? "+token+" ?>",value); |
||||
} |
||||
sb.append(copy).append("\n"); |
||||
} |
||||
sb.append(source.substring(end)); |
||||
return sb.toString(); |
||||
} |
||||
} |
||||
return source; |
||||
} |
||||
|
||||
private String renderTaxes(String source, Optional<JsonConfig> opt, PriceFormat price) { |
||||
if (opt.isPresent()){ |
||||
var taxes = opt.get(); |
||||
System.getLogger(id()).log(TRACE,"Taxes: {0}",taxes); |
||||
|
||||
var matcher = TAXES_PATTERN.matcher(source); |
||||
if (matcher.find()){ |
||||
var taxCode = matcher.group(1).trim(); |
||||
var start = matcher.start(1); |
||||
var end = matcher.end(1); |
||||
var tokens = findTokensIn(taxCode); |
||||
StringBuilder sb = new StringBuilder(); |
||||
sb.append(source, 0, start); |
||||
|
||||
for (var rate : taxes.keys()){ |
||||
var percent = Integer.parseInt(rate); |
||||
Optional<Long> o = taxes.get(rate); |
||||
long netSum = o.orElse(0L); |
||||
long tax = Math.round(percent * netSum / 100d); |
||||
long grossSum = netSum + tax; |
||||
|
||||
var copy = taxCode; |
||||
|
||||
for (var token : tokens){ |
||||
var value = switch (token){ |
||||
case "net_sum" -> price.format(netSum); |
||||
case "rate" -> rate+" %"; |
||||
case "amount" -> price.format(tax); |
||||
case "gross_sum" -> price.format(grossSum); |
||||
default -> null; |
||||
}; |
||||
if (value != null) copy = copy.replace("<? "+token+" ?>",value); |
||||
} |
||||
sb.append(copy).append("\n"); |
||||
} |
||||
|
||||
sb.append(source.substring(end)); |
||||
return sb.toString(); |
||||
} |
||||
} |
||||
return source; |
||||
} |
||||
|
||||
private int byIntValue(String a, String b) { |
||||
try { |
||||
var i1 = Integer.parseInt(a); |
||||
var i2 = Integer.parseInt(b); |
||||
return i1-i2; |
||||
} catch (NumberFormatException ignored){ |
||||
return a.compareTo(b); |
||||
} |
||||
} |
||||
|
||||
private static Set<String> findTokensIn(String content) { |
||||
var matcher = TOKEN_PATTERN.matcher(content); |
||||
var token = new HashSet<String>(); |
||||
while (matcher.find()) token.add(matcher.group(1)); |
||||
return token; |
||||
} |
||||
|
||||
protected abstract Document source(); |
||||
} |
||||
@ -0,0 +1,37 @@
@@ -0,0 +1,37 @@
|
||||
/* © SRSoftware 2025 */ |
||||
package de.srsoftware.umbrella.documents; |
||||
|
||||
import de.srsoftware.document.api.Document; |
||||
import de.srsoftware.document.api.DocumentFactory; |
||||
import de.srsoftware.document.api.DocumentRegistry; |
||||
|
||||
import java.util.stream.Stream; |
||||
|
||||
public class TemplateProcessor implements DocumentFactory { |
||||
|
||||
private DocumentRegistry registry; |
||||
|
||||
private Document createGenerator(final Document source) { |
||||
return new TemplateDoc() { |
||||
protected Document source() { |
||||
return source; |
||||
} |
||||
}; |
||||
} |
||||
|
||||
@Override |
||||
public String description() { |
||||
return "Creates documents from template files"; |
||||
} |
||||
|
||||
@Override |
||||
public Stream<Document> documents() { |
||||
return this.registry.documents().filter(document -> document.name().endsWith(".template")).map(this::createGenerator); |
||||
} |
||||
|
||||
@Override |
||||
public DocumentFactory setRegistry(DocumentRegistry registry) { |
||||
this.registry = registry; |
||||
return this; |
||||
} |
||||
} |
||||
@ -0,0 +1,224 @@
@@ -0,0 +1,224 @@
|
||||
<html> |
||||
<head> |
||||
<meta charset="UTF-8"> |
||||
<style> |
||||
body { |
||||
font-family: Arial, Helvetica, sans-serif; |
||||
font-size: 14px; |
||||
} |
||||
header{ |
||||
width: 100%; |
||||
height: 4cm; |
||||
position: relative; |
||||
} |
||||
header .receiver{ |
||||
position: absolute; |
||||
left: 0; |
||||
bottom: 0; |
||||
} |
||||
header .sender{ |
||||
position: absolute; |
||||
right: 0; |
||||
text-align: right; |
||||
} |
||||
header .company{ |
||||
font-size: 20px; |
||||
font-weight: bold; |
||||
} |
||||
header .address .small{ |
||||
font-size: 10px; |
||||
font-weight: bold; |
||||
padding-bottom: 5px; |
||||
} |
||||
header .address .small .break{ |
||||
display: inline; |
||||
} |
||||
header .address .small .break:after{ |
||||
content: ' / '; |
||||
} |
||||
span.break{ |
||||
display: block; |
||||
} |
||||
h2 { |
||||
position: relative; |
||||
text-align: center; |
||||
font-size: 14px; |
||||
font-weight: bold; |
||||
} |
||||
.bottom .company, |
||||
h2 .left { |
||||
position: absolute; |
||||
left: 0; |
||||
} |
||||
.bottom .bank_account, |
||||
h2 .right { |
||||
position: absolute; |
||||
right: 0; |
||||
} |
||||
.right{ |
||||
text-align: right; |
||||
} |
||||
.center{ |
||||
text-align: center; |
||||
} |
||||
tr { |
||||
vertical-align: baseline; |
||||
} |
||||
.foot, |
||||
.head{ |
||||
margin: 15px 0; |
||||
} |
||||
td.description{ |
||||
font-size: 12px; |
||||
color: #333; |
||||
} |
||||
header .logo{ |
||||
width: 150px; |
||||
position: absolute; |
||||
left: 37%; |
||||
top: -60px; |
||||
} |
||||
.bottom{ |
||||
width: 100%; |
||||
position: absolute; |
||||
bottom: 0; |
||||
text-align: center; |
||||
} |
||||
.bottom div{ |
||||
display: inline; |
||||
text-align: left; |
||||
top: 0; |
||||
} |
||||
th { |
||||
background: paleGreen; |
||||
} |
||||
table:not(.bottom) tr:nth-child(4n+1), |
||||
tr:nth-child(4n){ |
||||
background: #eaffea; |
||||
} |
||||
.ad{ |
||||
font-size: 10px; |
||||
margin-top: 20px; |
||||
} |
||||
table{ |
||||
width: 100%; |
||||
} |
||||
table.bottom td:nth-child(2n+1){ |
||||
text-align: left; |
||||
} |
||||
</style> |
||||
</head> |
||||
<body> |
||||
<header> |
||||
<svg width="42mm" height="37mm" version="1.1" viewBox="0 0 840 727" xmlns="http://www.w3.org/2000/svg" class="logo"> |
||||
<path d="m420 20c-200 0-400 120-400 300" fill="none" stroke="#1E8449" stroke-width="40"/> |
||||
<path d="m420 20c200 0 400 120 400 300" fill="none" stroke="#1E8449" stroke-width="40"/> |
||||
<path d="m380 160c-100-160-300 60-100 160" fill="none" stroke="#1E8449" stroke-width="40"/> |
||||
<path d="m276 318c194 102 94 332-104 238" fill="none" stroke="#1E8449" stroke-width="40"/> |
||||
<path d="m20 320c0 100 50 180 160 240" fill="none" stroke="#1E8449" stroke-width="40"/> |
||||
<line x1="440" x2="440" y1="80" y2="442" fill="none" stroke="#1E8449" stroke-width="40"/> |
||||
<path d="m440 320c180 0 152 92 232 192" fill="none" stroke="#1E8449" stroke-width="40"/> |
||||
<path d="m440 100c200 0 280 220 0 220" fill="none" stroke="#1E8449" stroke-width="40"/> |
||||
<path d="m626 578c-80 30-186 62-186-138" fill="none" stroke="#1E8449" stroke-width="40"/> |
||||
<path d="m620 580c116-40 200-150 200-260" fill="none" stroke="#1E8449" stroke-width="40"/> |
||||
<text x="100" y="700" fill="#1E8449" font-family="sans-serif" font-size="112.89px"><tspan x="100" y="700">SRSoftware</tspan></text> |
||||
</svg> |
||||
|
||||
<div class="company"><? document.company.name ?></div> |
||||
<div class="receiver address"> |
||||
<div class="small"><? document.sender.name ?></div> |
||||
<? document.customer.name ?> |
||||
</div> |
||||
<div class="sender data"> |
||||
<? document.sender.name ?><br/> |
||||
<br/> |
||||
<? Delivery date ?>: <? document.delivery ?> |
||||
</div> |
||||
</header> |
||||
<h1><? document.type ?></h1> |
||||
<h2> |
||||
<span class="left"><? document.type ?> <? document.number ?></span> |
||||
<span class="center"><? customer number ?>: <? document.customer.id ?></span> |
||||
<span class="right"><? document.date ?></span> |
||||
</h2> |
||||
<hr/> |
||||
<div class="head"><? document.head ?></div> |
||||
<table> |
||||
<tr> |
||||
<th><? Position ?></th> |
||||
<th><? Description ?></th> |
||||
<th><? Price per unit ?></th> |
||||
<th><? Amount ?></th> |
||||
<th><? Price ?></th> |
||||
<th><? Tax rate ?></th> |
||||
</tr> |
||||
|
||||
<!-- positions --> |
||||
|
||||
<tr> |
||||
<td class="center"><? number ?></td> |
||||
<td><b><? title ?></b></td> |
||||
<td class="right"><? unit_price ?></td> |
||||
<td class="right"><? amount ?> <? unit ?></td> |
||||
<td class="right"><? net_price ?></td> |
||||
<td class="right"><? tax ?> %</td> |
||||
</tr> |
||||
<tr> |
||||
<td><? optional ?></td> |
||||
<td colspan="5" class="description"><? description ?></td> |
||||
</tr> |
||||
|
||||
<!-- positions --> |
||||
|
||||
<tr> |
||||
<th class="right" colspan="5"><? Net sum ?>:</th> |
||||
<th class="right"><? document.net_sum ?></th> |
||||
</tr> |
||||
|
||||
<!-- tax list --> |
||||
|
||||
<tr class="right"> |
||||
<td></td> |
||||
<td><? net_sum ?> + <? rate ?> =</td> |
||||
<td><? gross_sum ?></td> |
||||
<td colspan="2"><? contained tax: ?></td> |
||||
<td><? amount ?></td> |
||||
</tr> |
||||
|
||||
<!-- tax list --> |
||||
<tr> |
||||
<th class="right" colspan="5"><? Gross sum ?>:</th> |
||||
<th class="right"><? document.gross_sum ?></th> |
||||
</tr> |
||||
</table> |
||||
<div class="foot"><? document.footer ?></div> |
||||
<table class="bottom"> |
||||
<tr> |
||||
<td><? document.sender.name ?></td> |
||||
<td> |
||||
<? Tax id ?>: <? document.sender.tax_id ?><br/> |
||||
<? local court ?>: <? document.sender.court ?><br/><br/> |
||||
<div class="ad"><? document.type ?> <? created with ?> <a href="https://umbrella.srsoftware.de">Umbrella</a> <? by ?> <a href="https://srsoftware.de">SRSoftware</a></div> |
||||
</td> |
||||
<td> |
||||
<? Bank account ?>:<br/> |
||||
<? document.sender.bank_account ?> |
||||
</td> |
||||
</tr> |
||||
</table> |
||||
<!-- <div class="bottom"> |
||||
<div class="company"> |
||||
<? document.sender.name ?> |
||||
</div> |
||||
<div class="tax_data"> |
||||
<? Tax id ?>: <? document.sender.tax_id ?><br/> |
||||
<? local court ?>: <? document.sender.court ?><br/><br/> |
||||
<div class="ad"><? document.type ?> <? created with ?> <a href="https://umbrella.srsoftware.de">Umbrella</a> <? by ?> <a href="https://srsoftware.de">SRSoftware</a></div> |
||||
</div> |
||||
<div class="bank_account"> |
||||
<? Bank account ?>:<br/> |
||||
<? document.sender.bank_account ?> |
||||
</div> |
||||
</div> --> |
||||
</body> |
||||
</html> |
||||
Loading…
Reference in new issue