From 877edadd50595b266d3d49f4c64f2a06f010f3c9 Mon Sep 17 00:00:00 2001 From: Stephan Richter Date: Wed, 16 Jul 2025 18:21:35 +0200 Subject: [PATCH] working on document rendering --- .../umbrella/backend/Application.java | 2 +- .../srsoftware/umbrella/core/BaseHandler.java | 5 +- .../de/srsoftware/umbrella/core/Tuple.java | 2 +- .../core/exceptions/UmbrellaException.java | 5 + documents/build.gradle.kts | 1 + .../umbrella/documents/Constants.java | 2 + .../umbrella/documents/DocumentApi.java | 151 +++++++++++- .../umbrella/documents/PriceFormat.java | 6 + .../umbrella/documents/TemplateDoc.java | 188 +++++++++++++++ .../umbrella/documents/TemplateProcessor.java | 37 +++ .../resources/SRSoftware 2020.html.template | 224 ++++++++++++++++++ 11 files changed, 611 insertions(+), 12 deletions(-) create mode 100644 documents/src/main/java/de/srsoftware/umbrella/documents/PriceFormat.java create mode 100644 documents/src/main/java/de/srsoftware/umbrella/documents/TemplateDoc.java create mode 100644 documents/src/main/java/de/srsoftware/umbrella/documents/TemplateProcessor.java create mode 100644 documents/src/main/resources/SRSoftware 2020.html.template diff --git a/backend/src/main/java/de/srsoftware/umbrella/backend/Application.java b/backend/src/main/java/de/srsoftware/umbrella/backend/Application.java index a6bbf28..6944492 100644 --- a/backend/src/main/java/de/srsoftware/umbrella/backend/Application.java +++ b/backend/src/main/java/de/srsoftware/umbrella/backend/Application.java @@ -60,7 +60,7 @@ public class Application { var userModule = new UserModule(config,messageSystem); var companyModule = new CompanyModule(config, userModule); - var documentApi = new DocumentApi(companyModule, config); + var documentApi = new DocumentApi(companyModule, translationModule, config); var itemApi = new ItemApi(config,companyModule); var legacyApi = new LegacyApi(userModule.userDb(),config); var markdownApi = new MarkdownApi(userModule); diff --git a/core/src/main/java/de/srsoftware/umbrella/core/BaseHandler.java b/core/src/main/java/de/srsoftware/umbrella/core/BaseHandler.java index a07850c..14ada46 100644 --- a/core/src/main/java/de/srsoftware/umbrella/core/BaseHandler.java +++ b/core/src/main/java/de/srsoftware/umbrella/core/BaseHandler.java @@ -5,7 +5,6 @@ import static de.srsoftware.tools.Optionals.nullable; import static java.lang.System.Logger.Level.DEBUG; import static java.lang.System.Logger.Level.WARNING; import static java.net.HttpURLConnection.*; -import static java.text.MessageFormat.format; import com.sun.net.httpserver.HttpExchange; import de.srsoftware.tools.Path; @@ -72,7 +71,5 @@ public abstract class BaseHandler extends PathHandler { return sendEmptyResponse(HTTP_UNAUTHORIZED,ex); } - public boolean notImplemented(HttpExchange ex,String method,Object clazz) throws IOException{ - return sendContent(ex,HTTP_NOT_IMPLEMENTED,format("{0}.{1} not implemented",clazz.getClass().getSimpleName(),method)); - } + } diff --git a/core/src/main/java/de/srsoftware/umbrella/core/Tuple.java b/core/src/main/java/de/srsoftware/umbrella/core/Tuple.java index a1a7225..351cb88 100644 --- a/core/src/main/java/de/srsoftware/umbrella/core/Tuple.java +++ b/core/src/main/java/de/srsoftware/umbrella/core/Tuple.java @@ -1,6 +1,6 @@ +/* © SRSoftware 2025 */ package de.srsoftware.umbrella.core; -import de.srsoftware.umbrella.core.model.Company; public class Tuple{ public final A a; diff --git a/core/src/main/java/de/srsoftware/umbrella/core/exceptions/UmbrellaException.java b/core/src/main/java/de/srsoftware/umbrella/core/exceptions/UmbrellaException.java index 7fcefcf..34552a9 100644 --- a/core/src/main/java/de/srsoftware/umbrella/core/exceptions/UmbrellaException.java +++ b/core/src/main/java/de/srsoftware/umbrella/core/exceptions/UmbrellaException.java @@ -7,6 +7,7 @@ import static de.srsoftware.umbrella.core.ResponseCode.HTTP_UNPROCESSABLE; import static java.lang.System.Logger.Level.ERROR; import static java.lang.System.Logger.Level.WARNING; import static java.net.HttpURLConnection.HTTP_FORBIDDEN; +import static java.net.HttpURLConnection.HTTP_NOT_FOUND; import static java.text.MessageFormat.format; @@ -51,6 +52,10 @@ public class UmbrellaException extends Exception{ return new UmbrellaException(HTTP_UNPROCESSABLE, ERROR_MISSING_FIELD, field); } + public static UmbrellaException notFound(String message, Object... fills) { + return new UmbrellaException(HTTP_NOT_FOUND,message,fills); + } + public int statusCode(){ return statusCode; } diff --git a/documents/build.gradle.kts b/documents/build.gradle.kts index 08d06aa..d7a0f76 100644 --- a/documents/build.gradle.kts +++ b/documents/build.gradle.kts @@ -4,6 +4,7 @@ dependencies{ implementation(project(":company")) implementation(project(":core")) + implementation("de.srsoftware:configuration.json:1.0.3") implementation("de.srsoftware:document.api:1.0.1") implementation("de.srsoftware:document.file:1.0.0") implementation("de.srsoftware:document.processor:1.0.2") diff --git a/documents/src/main/java/de/srsoftware/umbrella/documents/Constants.java b/documents/src/main/java/de/srsoftware/umbrella/documents/Constants.java index 43ce91f..9c0a5be 100644 --- a/documents/src/main/java/de/srsoftware/umbrella/documents/Constants.java +++ b/documents/src/main/java/de/srsoftware/umbrella/documents/Constants.java @@ -16,7 +16,9 @@ public class Constants { public static final String COMPANY = "company"; public static final String CONFIG_DATABASE = "umbrella.modules.document.database"; + public static final String CONFIG_TEMPLATES = "umbrella.modules.document.templates"; public static final String CONTACTS = "contacts"; + public static final String CONTENT_DISPOSITION = "Content-Disposition"; public static final String CUSTOMERS = "customers"; public static final String ERROR_ADDRESS_MISSING = "{0} address does not contain street address / post code / city"; diff --git a/documents/src/main/java/de/srsoftware/umbrella/documents/DocumentApi.java b/documents/src/main/java/de/srsoftware/umbrella/documents/DocumentApi.java index 555872e..f1bd72d 100644 --- a/documents/src/main/java/de/srsoftware/umbrella/documents/DocumentApi.java +++ b/documents/src/main/java/de/srsoftware/umbrella/documents/DocumentApi.java @@ -1,8 +1,14 @@ /* © SRSoftware 2025 */ package de.srsoftware.umbrella.documents; +import static de.srsoftware.document.mustang.Constants.*; +import static de.srsoftware.document.mustang.Constants.KEY_DATA; +import static de.srsoftware.document.mustang.Constants.KEY_PDF; +import static de.srsoftware.document.mustang.Constants.KEY_PRODUCER; +import static de.srsoftware.document.mustang.Constants.KEY_TEMPLATE; import static de.srsoftware.tools.MimeType.MIME_FORM_URL; import static de.srsoftware.tools.Optionals.isSet; +import static de.srsoftware.tools.Strings.escapeHtmlEntities; import static de.srsoftware.umbrella.core.ConnectionProvider.connect; import static de.srsoftware.umbrella.core.Constants.*; import static de.srsoftware.umbrella.core.Paths.LIST; @@ -19,24 +25,36 @@ import static java.util.stream.Collectors.toMap; import com.sun.net.httpserver.HttpExchange; import de.srsoftware.configuration.Configuration; +import de.srsoftware.document.api.Content; import de.srsoftware.document.api.DocumentRegistry; +import de.srsoftware.document.api.RenderError; +import de.srsoftware.document.files.DocumentDirectory; +import de.srsoftware.document.processor.latex.LatexFactory; +import de.srsoftware.document.processor.weasyprint.WeasyFactory; +import de.srsoftware.document.zugferd.ZugferdFactory; +import de.srsoftware.document.zugferd.data.*; +import de.srsoftware.document.zugferd.data.Currency; import de.srsoftware.tools.Pair; import de.srsoftware.tools.Path; import de.srsoftware.tools.SessionToken; import de.srsoftware.umbrella.core.BaseHandler; import de.srsoftware.umbrella.core.Tuple; import de.srsoftware.umbrella.core.api.CompanyService; +import de.srsoftware.umbrella.core.api.Translator; import de.srsoftware.umbrella.core.api.UserService; import de.srsoftware.umbrella.core.exceptions.UmbrellaException; import de.srsoftware.umbrella.core.model.Company; import de.srsoftware.umbrella.core.model.Token; import de.srsoftware.umbrella.core.model.UmbrellaUser; import de.srsoftware.umbrella.documents.model.*; +import de.srsoftware.umbrella.documents.model.Customer; + +import java.io.File; import java.io.IOException; +import java.text.MessageFormat; import java.time.LocalDate; -import java.util.HashMap; -import java.util.Map; -import java.util.Optional; +import java.util.*; +import java.util.function.Function; import java.util.function.Predicate; import java.util.stream.Stream; import org.json.JSONArray; @@ -50,13 +68,22 @@ public class DocumentApi extends BaseHandler { private final Configuration config; private final DocumentDb db; private final UserService users; + private final Translator translator; - public DocumentApi(CompanyService companyService, Configuration config) throws UmbrellaException { + public DocumentApi(CompanyService companyService, Translator translator, Configuration config) throws UmbrellaException { this.config = config; + this.translator = translator; var dbFile = config.get(CONFIG_DATABASE).orElseThrow(() -> missingFieldException(CONFIG_DATABASE)); db = new SqliteDb(connect(dbFile)); companies = companyService; users = companyService.userService(); + + Optional templates = config.get(CONFIG_TEMPLATES); + if (templates.isEmpty()) throw missingFieldException(CONFIG_TEMPLATES); + registry.add(new DocumentDirectory(new File(templates.get()))); + registry.add(new TemplateProcessor(), new LatexFactory(), new WeasyFactory(), new ZugferdFactory()); + + registry.documents().forEach(d -> LOG.log(DEBUG,"found template: {0}",d)); } @Override @@ -233,6 +260,86 @@ public class DocumentApi extends BaseHandler { return sendContent(ex,doc.renderToMap()); } + private PriceFormat priceFormat(String currency, String language) { + var pattern = switch (currency){ + case "$" -> "$ {0,number,#,###.00}"; + default -> "{0,number,#,###.00} "+currency; + }; + var message = new MessageFormat(pattern, "de".equals(language)? Locale.GERMAN:Locale.US); + return val -> message.format(new Object[]{val/100d}); + } + + private DocumentData convert(Document document) throws Converter.ConversionError { + var currency = switch (document.currency()){ + case "€" -> Currency.EUR; + case "$" -> Currency.USD; + default -> throw new Converter.ConversionError("Unsupported currency: ",document.currency()); + }; + var typeCode = switch (document.type().name()){ + case "invoice" -> TypeCode.HANDELSRECHNUNG; + default -> throw new Converter.ConversionError("Unsupported document type: ",document.type().name()); + }; + var countryID = CountryCode.DE; + LOG.log(WARNING,"countryID is hardcoded to be \"DE\", should be field of company!"); + var sender = document.sender(); + + var match = POST_CODE.matcher(sender.name()); + if (!match.find()) throw new Converter.ConversionError(ERROR_ADDRESS_MISSING, SENDER); + var name = match.group(1).trim(); + var streetAddress = match.group(2).trim(); + var postCode = match.group(3); + var city = match.group(4).trim(); + var taxNumber = sender.taxNumber(); + if (!taxNumber.startsWith("DE")) throw new Converter.ConversionError("Invalid sender tax number ({0})!",taxNumber); + var author = new Author(name,countryID,postCode,city,streetAddress,taxNumber); + + match = POST_CODE.matcher(document.customer().name()); + if (!match.find()) throw new Converter.ConversionError(ERROR_ADDRESS_MISSING,FIELD_CUSTOMER); + name = escapeHtmlEntities(match.group(1).trim()); + streetAddress = escapeHtmlEntities(match.group(2).trim()); + postCode = match.group(3); + city = match.group(4).trim(); + var customer = new de.srsoftware.document.zugferd.data.Customer(name,countryID,postCode,streetAddress,city); + var footer = document.footer(); + var head = document.head(); + + var notes = new ArrayList(); + if (!head.isBlank()) notes.add(head); + if (!footer.isBlank()) notes.add(footer); + LocalDate deliveryDate; + try { + deliveryDate = LocalDate.parse(document.delivery()); + } catch (RuntimeException ex) { + throw new Converter.ConversionError("\"{0}\" is not a valid delivery date (cannot be parsed)!",document.delivery()); + } + var lineItems = new ArrayList(); + for (var entry : document.positions().entrySet()){ + var number = entry.getKey(); + var pos = entry.getValue(); + + UnitCode unit = switch (pos.unit()){ + case "jährlich" -> UnitCode.per_year; + case "pauschal" -> UnitCode.fixed; + case "h", "Stunden" -> UnitCode.hours; + case "Stück" -> UnitCode.pieces; + default -> throw new Converter.ConversionError("No unit code defined for {0}",pos.unit()); + }; + var percent = pos.tax(); + var taxType = percent == 0 ? TaxType.Z : TaxType.S; + LOG.log(WARNING,"tax type is hardcoded to be \"{0}\", should be field of document / document position!",taxType.name()); + + var taxSet = new LineItemTaxSet(percent*100L,taxType); + // bei Gutschriften soll die Menge negativ sein, nicht aber der Einheitspreis. Deshalb wird das ggf. invertiert. + var neg = pos.unitPrice() < 0; + var unitPrice = (neg ? -1 : 1) * pos.unitPrice(); + var amount = (neg ? -1 : 1) * pos.amount(); + lineItems.add(new LineItem(number,pos.itemCode(),pos.title(),pos.description(),null,unitPrice,unit,amount,taxSet)); + } + String terms = footer; + return new DocumentData(document.number(),currency,typeCode,document.date(),author,customer,notes,deliveryDate,null,terms,lineItems); + } + + private boolean getRenderedDocument(HttpExchange ex, long docId, UmbrellaUser user) throws IOException, UmbrellaException { var document = getDocumentWithCompanyData(docId,user); var template = document.template().name(); @@ -243,9 +350,41 @@ public class DocumentApi extends BaseHandler { var optDoc = registry.documents() .filter(filter) .findAny(); - if (optDoc.isEmpty()) throw new UmbrellaException(404,"Cannot render {0} {1}: Missing template \"{2}\"",type,document.number(),template); + if (optDoc.isEmpty()) throw UmbrellaException.notFound("Cannot render {0} {1}: Missing template \"{2}\"",type,document.number(),template); + Function translate = text -> translator.translate(user.language(),text); + var pdfData = new HashMap(); + pdfData.put(FIELD_DOCUMENT,document.renderToMap()); + pdfData.put("translate",translate); + pdfData.put(USER,user); + pdfData.put(FIELD_PRICE_FORMAT, priceFormat(document.currency(),user.language())); + Map data = pdfData; + if (zugferd) try { // create additional data + data = Map.of( + KEY_XML,Map.of( + KEY_PROFILE,"EN16931", + KEY_DATA,convert(document) + ), + KEY_PDF,Map.of( + KEY_TEMPLATE, templateName, + KEY_DATA, pdfData + ), + KEY_PRODUCER,"SRSoftware Umbrella" + ); + } catch (Converter.ConversionError e){ + throw new UmbrellaException(500,"Failed to convert data: {0}",e.getMessage()).causedBy(e); + } + + var rendered = optDoc.get().render(data); + var source = optDoc.get(); + if (rendered instanceof RenderError err) throw new UmbrellaException(500,"Failed to render {0}: {1}",source.name(),err.toString()); + if (rendered instanceof Content content) { + var headers = ex.getResponseHeaders(); + headers.add(CONTENT_TYPE, source.mimeType()); + headers.add(CONTENT_DISPOSITION,"attachment; filename=\""+document.number()+".pdf\""); + return sendContent(ex,content.bytes()); + } + throw new UmbrellaException(500,"Unknown result type ({0}) returned from render process!",rendered.getClass().getSimpleName()); - return sendContent(ex,document.renderToMap()); } private JSONArray getLegacyContacts(HttpExchange ex, UmbrellaUser umbrellaUser, Token token) throws IOException, UmbrellaException { diff --git a/documents/src/main/java/de/srsoftware/umbrella/documents/PriceFormat.java b/documents/src/main/java/de/srsoftware/umbrella/documents/PriceFormat.java new file mode 100644 index 0000000..aa8a0f8 --- /dev/null +++ b/documents/src/main/java/de/srsoftware/umbrella/documents/PriceFormat.java @@ -0,0 +1,6 @@ +/* © SRSoftware 2025 */ +package de.srsoftware.umbrella.documents; + +public interface PriceFormat { + String format(long val); +} \ No newline at end of file diff --git a/documents/src/main/java/de/srsoftware/umbrella/documents/TemplateDoc.java b/documents/src/main/java/de/srsoftware/umbrella/documents/TemplateDoc.java new file mode 100644 index 0000000..dd02cd3 --- /dev/null +++ b/documents/src/main/java/de/srsoftware/umbrella/documents/TemplateDoc.java @@ -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("(.*)",Pattern.DOTALL); + private static final Pattern TAXES_PATTERN = Pattern.compile("(.*)",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 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","\n"); + source = source.replace("",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","\n"); + source = source.replace("",value); + } + return new StringContent(source); + } + return precursor; + } + + private String renderPositions(String source, Optional 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("",value); + } + sb.append(copy).append("\n"); + } + sb.append(source.substring(end)); + return sb.toString(); + } + } + return source; + } + + private String renderTaxes(String source, Optional 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 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("",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 findTokensIn(String content) { + var matcher = TOKEN_PATTERN.matcher(content); + var token = new HashSet(); + while (matcher.find()) token.add(matcher.group(1)); + return token; + } + + protected abstract Document source(); +} \ No newline at end of file diff --git a/documents/src/main/java/de/srsoftware/umbrella/documents/TemplateProcessor.java b/documents/src/main/java/de/srsoftware/umbrella/documents/TemplateProcessor.java new file mode 100644 index 0000000..ab9d46d --- /dev/null +++ b/documents/src/main/java/de/srsoftware/umbrella/documents/TemplateProcessor.java @@ -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 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; + } +} diff --git a/documents/src/main/resources/SRSoftware 2020.html.template b/documents/src/main/resources/SRSoftware 2020.html.template new file mode 100644 index 0000000..ebcd534 --- /dev/null +++ b/documents/src/main/resources/SRSoftware 2020.html.template @@ -0,0 +1,224 @@ + + + + + + +
+ + +
+
+
+ +
+
+
+
+ : +
+
+

+

+ + : + +

+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
  %
:
 +  =
:
+
+ + + + + + +
+ :
+ :

+ +
+ :
+ +
+ + + \ No newline at end of file